MediaPipe Face Landmarker による顔の変化分析(ソースコードと実行結果)

Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する


pip install mediapipe opencv-python matplotlib numpy

MediaPipe Face Landmarker による顔の変化分析プログラム

概要

本プログラムは、動画像から人間の顔を検出し、3次元ランドマークと表情係数を用いて顔の動きを解析する。具体的には、目の開閉(瞬き)、口の開き具合、眉の動き、笑顔の度合いなどの表情変化をリアルタイムで数値化し、時系列データとして可視化する。これは人間の表情という非言語的コミュニケーションを機械が理解可能な形式に変換する能力である。

主要技術

参考文献

[1] Lugaresi, C., Tang, J., Nash, H., McClanahan, C., Uboweja, E., Hays, M., Zhang, F., Chang, C. L., Yong, M. G., Lee, J., Chang, W. T., Hua, W., Georg, M., & Grundmann, M. (2019). MediaPipe: A Framework for Building Perception Pipelines. arXiv preprint arXiv:1906.08172.

[2] Kartynnik, Y., Ablavatski, A., Grishchenko, I., & Grundmann, M. (2019). Real-time Facial Surface Geometry from Monocular Video on Mobile GPUs. CVPR Workshop on Computer Vision for Augmented and Virtual Reality.

[3] Smith, S. W. (1997). The Scientist and Engineer's Guide to Digital Signal Processing. California Technical Publishing.


# 顔の変化分析プログラム
# 特徴技術名: MediaPipe Face Landmarker
# 出典: Google MediaPipe Face Landmarker (2023)
#       https://developers.google.com/mediapipe/solutions/vision/face_landmarker
# 特徴機能: 478個の高密度3Dランドマークと52個の表情係数によるリアルタイム顔追跡で目・口・眉の微細な動きを正確に検出
# 学習済みモデル: MediaPipe Face Landmarker Model(顔の3Dメッシュ推定と表情分析用モデル、自動ダウンロード)
# 方式設計:
#   - 関連利用技術: OpenCV(動画処理・表示)、matplotlib(リアルタイムグラフ描画)、numpy(ベクトル計算)
#   - 入力と出力: 入力: 動画(ユーザは「0:動画ファイル,1:カメラ,2:サンプル動画」のメニューで選択.0:動画ファイルの場合はtkinterでファイル選択.1の場合はOpenCVでカメラが開く.2の場合はhttps://github.com/opencv/opencv/blob/master/samples/data/vtest.aviを使用)
#                出力: 処理結果が画像化できる場合にはOpenCV画面でリアルタイムに表示.OpenCV画面内に処理結果をテキストで表示.さらに,1秒間隔で,print()で処理結果を表示.プログラム終了時にprint()で表示した処理結果をresult.txtファイルに保存し,「result.txtに保存」したことをprint()で表示.プログラム開始時に,プログラムの概要,ユーザが行う必要がある操作(もしあれば)をprint()で表示.
#   - 処理手順: 動画フレーム取得→Face Landmarkerで478個の3Dランドマーク検出→EAR/MAR/眉高さ計算→移動平均→グラフ更新→統合表示
#   - 前処理,後処理: 前処理: BGR→RGB色空間変換(MediaPipeの要求仕様)、後処理: 特徴量の移動平均によるノイズ除去
#   - 追加処理: 瞬き検出のためのEAR(Eye Aspect Ratio)計算、口の開き具合のMAR(Mouth Aspect Ratio)計算、眉の相対高さ計算、表情係数の活用
#   - 調整を必要とする設定値: WINDOW_SIZE(移動平均のウィンドウサイズ、デフォルト10)- ノイズ除去の強度を制御、
#                           GRAPH_LENGTH(グラフ表示のデータ点数、デフォルト100)- 表示する履歴の長さを制御
# 将来方策: 動的に顔の動きの速度を検出し、WINDOW_SIZEを自動調整する機能の実装
# その他の重要事項: 478個の3Dランドマークと52個の表情係数を活用した高精度な顔分析
# 前準備:
#   - pip install mediapipe opencv-python matplotlib numpy

import os
import logging
import warnings
import sys
import io
from contextlib import redirect_stderr
import urllib.request
import time

# ログレベルとTensorFlowメッセージの抑制
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
os.environ['GLOG_minloglevel'] = '3'
logging.getLogger('tensorflow').setLevel(logging.ERROR)
warnings.filterwarnings('ignore')

import cv2
import mediapipe as mp
from mediapipe.tasks import python
from mediapipe.tasks.python import vision
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_agg import FigureCanvasAgg
import tkinter as tk
from tkinter import filedialog
from collections import deque

# 調整可能な設定値
WINDOW_SIZE = 10    # 移動平均のウィンドウサイズ(ノイズ除去の強度)
GRAPH_LENGTH = 100  # グラフに表示するデータ点数(履歴の長さ)

# Face Landmarker設定値
MIN_DETECTION_CONFIDENCE = 0.5  # 顔検出の最小信頼度
MIN_TRACKING_CONFIDENCE = 0.5   # トラッキングの最小信頼度

# グラフ表示設定
EAR_Y_MAX = 0.4     # EARグラフのY軸最大値
MAR_Y_MAX = 0.8     # MARグラフのY軸最大値
EYEBROW_Y_MAX = 0.2 # 眉グラフのY軸最大値
SCORE_Y_MAX = 1.0   # スコアグラフのY軸最大値

# 表示設定
LANDMARK_COLOR = (0, 255, 0)  # ランドマークの色(BGR)
LANDMARK_SIZE = 2             # ランドマークのサイズ
TEXT_COLOR = (0, 255, 0)      # テキストの色(BGR)
TEXT_SCALE = 0.7              # テキストのスケール
TEXT_THICKNESS = 2            # テキストの太さ
TEXT_LINE_HEIGHT = 30         # テキスト行間

# グラフ設定
GRAPH_DPI = 100              # グラフのDPI
GRAPH_ALPHA = 0.3            # グリッドの透明度
GRAPH_LINEWIDTH = 2          # グラフの線幅
GRAPH_FONTSIZE = 10          # グラフのフォントサイズ

# モデル設定
MODEL_PATH = 'face_landmarker.task'
MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task'

# サンプル動画URL
SAMPLE_VIDEO_URL = 'https://github.com/opencv/opencv/blob/master/samples/data/vtest.avi?raw=true'
SAMPLE_VIDEO_NAME = 'vtest.avi'

# 特徴量計算用のランドマークインデックス(478個のランドマークに対応)
LANDMARK_INDICES = {
    'left_eye': [33, 160, 158, 133, 153, 144],
    'right_eye': [362, 385, 387, 263, 373, 380],
    'mouth': [61, 84, 17, 314, 405, 320, 307, 375, 321, 308],
    'left_eyebrow': [46, 53, 52, 65, 55],
    'right_eyebrow': [276, 283, 282, 295, 285]
}

# プログラム概要の表示
print('=== 顔の変化分析プログラム ===')
print('このプログラムは、MediaPipe Face Landmarkerを使用して顔の微細な変化を分析します。')
print('478個の3Dランドマークから、目の開き具合(瞬き)、口の開き具合、眉の動きなどを検出します。')
print('操作方法: 動画表示中に「q」キーで終了します。')
print()

# モデルファイルの自動ダウンロード
if not os.path.exists(MODEL_PATH):
    print('モデルをダウンロード中...')
    try:
        urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
        print('ダウンロード完了')
    except Exception as e:
        print(f'モデルのダウンロードに失敗しました: {MODEL_URL}')
        print(f'エラー: {e}')
        exit()

# MediaPipe Face Landmarker初期化(GPU/CPUフォールバック対応)
base_options = python.BaseOptions(
    model_asset_path=MODEL_PATH,
    delegate=python.BaseOptions.Delegate.GPU
)
options = vision.FaceLandmarkerOptions(
    base_options=base_options,
    running_mode=vision.RunningMode.VIDEO,
    num_faces=1,
    min_face_detection_confidence=MIN_DETECTION_CONFIDENCE,
    min_tracking_confidence=MIN_TRACKING_CONFIDENCE,
    output_face_blendshapes=True,
    output_facial_transformation_matrixes=False
)

# GPU/CPUフォールバック
try:
    landmarker = vision.FaceLandmarker.create_from_options(options)
    print('GPUモードで実行します。')
except:
    print('GPU使用不可。CPUモードで実行します。')
    base_options.delegate = python.BaseOptions.Delegate.CPU
    options.base_options = base_options
    landmarker = vision.FaceLandmarker.create_from_options(options)


def calc_ear(eye_lm):
    """Eye Aspect Ratio(目の開き具合)を計算"""
    # 垂直距離の計算
    v1 = np.linalg.norm(eye_lm[1] - eye_lm[5])
    v2 = np.linalg.norm(eye_lm[2] - eye_lm[4])
    # 水平距離の計算
    h = np.linalg.norm(eye_lm[0] - eye_lm[3])
    # EARの計算
    return (v1 + v2) / (2.0 * h) if h > 0 else 0


def calc_mar(mouth_lm):
    """Mouth Aspect Ratio(口の開き具合)を計算"""
    # 垂直距離の計算
    v1 = np.linalg.norm(mouth_lm[2] - mouth_lm[7])
    v2 = np.linalg.norm(mouth_lm[3] - mouth_lm[6])
    v3 = np.linalg.norm(mouth_lm[4] - mouth_lm[5])
    # 水平距離の計算
    h = np.linalg.norm(mouth_lm[0] - mouth_lm[1])
    # MARの計算
    return (v1 + v2 + v3) / (3.0 * h) if h > 0 else 0


def calc_eyebrow_height(eyebrow_lm, eye_center):
    """眉の高さ(目の中心からの相対距離)を計算"""
    eyebrow_center = np.mean(eyebrow_lm, axis=0)
    height = eyebrow_center[1] - eye_center[1]
    return abs(height)


def extract_features(landmarks, blendshapes=None):
    """3Dランドマークから特徴量を抽出"""
    # ランドマークをnumpy配列に変換
    lm_array = np.array([[lm.x, lm.y, lm.z] for lm in landmarks])

    # 特徴量の計算
    features = {
        'ear': -1.0,
        'mar': -1.0,
        'eyebrow': -1.0,
        'left_ear': 0.0,
        'right_ear': 0.0,
        'smile': -1.0,
        'brow_down': -1.0
    }

    # 目のEAR計算
    if all(idx < len(lm_array) for idx in LANDMARK_INDICES['left_eye'] + LANDMARK_INDICES['right_eye']):
        left_ear = calc_ear(lm_array[LANDMARK_INDICES['left_eye']])
        right_ear = calc_ear(lm_array[LANDMARK_INDICES['right_eye']])
        features['left_ear'] = left_ear
        features['right_ear'] = right_ear
        features['ear'] = (left_ear + right_ear) / 2.0

    # 口のMAR計算
    if all(idx < len(lm_array) for idx in LANDMARK_INDICES['mouth']):
        features['mar'] = calc_mar(lm_array[LANDMARK_INDICES['mouth']])

    # 眉の高さ計算
    all_indices = (LANDMARK_INDICES['left_eyebrow'] + LANDMARK_INDICES['right_eyebrow'] +
                   LANDMARK_INDICES['left_eye'] + LANDMARK_INDICES['right_eye'])
    if all(idx < len(lm_array) for idx in all_indices):
        # 左眉
        left_eye_center = np.mean(lm_array[LANDMARK_INDICES['left_eye']], axis=0)
        left_height = calc_eyebrow_height(lm_array[LANDMARK_INDICES['left_eyebrow']], left_eye_center)
        # 右眉
        right_eye_center = np.mean(lm_array[LANDMARK_INDICES['right_eye']], axis=0)
        right_height = calc_eyebrow_height(lm_array[LANDMARK_INDICES['right_eyebrow']], right_eye_center)
        features['eyebrow'] = (left_height + right_height) / 2.0

    # Blendshapesから表情情報を取得
    if blendshapes:
        smile = 0.0
        brow_down = 0.0
        for bs in blendshapes:
            if bs.category_name in ['mouthSmileLeft', 'mouthSmileRight']:
                smile += bs.score
            elif bs.category_name in ['browDownLeft', 'browDownRight']:
                brow_down += bs.score
        features['smile'] = smile / 2.0
        features['brow_down'] = brow_down / 2.0

    return features


def create_graph_image(data_hist, width, height):
    """特徴量グラフを画像として生成"""
    fig, axes = plt.subplots(5, 1, figsize=(width/GRAPH_DPI, height/GRAPH_DPI), dpi=GRAPH_DPI)
    fig.patch.set_facecolor('white')

    # データの準備
    x = list(range(len(data_hist['ear'])))

    # グラフ設定のリスト
    graph_configs = [
        ('ear', 'b-', EAR_Y_MAX, 'Eye Aspect Ratio', 'Eye Opening (Blink Detection)'),
        ('mar', 'g-', MAR_Y_MAX, 'Mouth Aspect Ratio', 'Mouth Opening'),
        ('eyebrow', 'r-', EYEBROW_Y_MAX, 'Eyebrow Height', 'Eyebrow Movement'),
        ('smile', 'orange', SCORE_Y_MAX, 'Smile Score', 'Smile Detection'),
        ('brow_down', 'purple', SCORE_Y_MAX, 'Brow Down Score', 'Brow Furrowing')
    ]

    # 各グラフの描画
    for i, (key, color, y_max, ylabel, title) in enumerate(graph_configs):
        y_data = data_hist[key]
        valid_data = [v if v >= 0 else None for v in y_data]
        axes[i].plot(x, valid_data, color, linewidth=GRAPH_LINEWIDTH)
        axes[i].set_ylabel(ylabel)
        axes[i].set_ylim([0, y_max])
        axes[i].grid(True, alpha=GRAPH_ALPHA)
        axes[i].set_title(title, fontsize=GRAPH_FONTSIZE)

    axes[-1].set_xlabel('Frame')
    plt.tight_layout()

    # 画像に変換
    canvas = FigureCanvasAgg(fig)
    canvas.draw()

    # バッファからRGB画像を取得
    buf = canvas.buffer_rgba()
    w_canvas, h_canvas = canvas.get_width_height()

    # numpy配列に変換
    graph_img = np.asarray(buf).reshape(h_canvas, w_canvas, 4)[:, :, :3]

    # リサイズ
    graph_img = cv2.resize(graph_img, (width, height))

    plt.close(fig)
    return graph_img


def process_frame(frame, lm_detector, data_hist, avg_buf, frame_cnt, fps, frame_size):
    """動画フレームを処理"""
    h, w = frame_size

    # RGB変換(MediaPipeの前処理)
    rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

    # MediaPipe用の画像形式に変換
    mp_img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_frame)

    # タイムスタンプ計算(ミリ秒)
    timestamp_ms = int(frame_cnt * 1000 / fps)

    # Face Landmarker処理
    with redirect_stderr(io.StringIO()):
        results = lm_detector.detect_for_video(mp_img, timestamp_ms)

    current_feat = None
    if results.face_landmarks:
        landmarks = results.face_landmarks[0]
        blendshapes = results.face_blendshapes[0] if results.face_blendshapes else None

        # 特徴量抽出
        features = extract_features(landmarks, blendshapes)
        current_feat = features

        # 移動平均の計算(後処理)
        for key in ['ear', 'mar', 'eyebrow', 'smile', 'brow_down']:
            if features[key] >= 0:  # 有効な値の場合のみ処理
                avg_buf[key].append(features[key])
                avg_val = np.mean(avg_buf[key])
                data_hist[key].append(avg_val)
            else:  # 無効な値の場合は-1を追加
                data_hist[key].append(-1)

        # ランドマーク描画
        for idx_list in LANDMARK_INDICES.values():
            for idx in idx_list:
                if idx < len(landmarks):
                    lm = landmarks[idx]
                    x = int(lm.x * w)
                    y = int(lm.y * h)
                    cv2.circle(frame, (x, y), LANDMARK_SIZE, LANDMARK_COLOR, -1)

        # フレーム内にテキスト表示
        if len(data_hist['ear']) > 0:
            y_pos = TEXT_LINE_HEIGHT
            texts = [
                f"EAR: {data_hist['ear'][-1]:.3f}",
                f"MAR: {data_hist['mar'][-1]:.3f}",
                f"Eyebrow: {data_hist['eyebrow'][-1]:.3f}"
            ]
            for text in texts:
                cv2.putText(frame, text, (10, y_pos), cv2.FONT_HERSHEY_SIMPLEX,
                           TEXT_SCALE, TEXT_COLOR, TEXT_THICKNESS)
                y_pos += TEXT_LINE_HEIGHT

    return frame, current_feat


# 入力選択
print('0: 動画ファイル')
print('1: カメラ')
print('2: サンプル動画')

choice = input('選択: ')
temp_file = None

if choice == '0':
    root = tk.Tk()
    root.withdraw()
    path = filedialog.askopenfilename()
    if not path:
        exit()
    cap = cv2.VideoCapture(path)
elif choice == '1':
    cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
elif choice == '2':
    # サンプル動画ダウンロード
    try:
        urllib.request.urlretrieve(SAMPLE_VIDEO_URL, SAMPLE_VIDEO_NAME)
        temp_file = SAMPLE_VIDEO_NAME
        cap = cv2.VideoCapture(SAMPLE_VIDEO_NAME)
    except Exception as e:
        print(f'動画のダウンロードに失敗しました: {SAMPLE_VIDEO_URL}')
        print(f'エラー: {e}')
        exit()
else:
    print('無効な選択です')
    exit()

# 初期フレーム取得とサイズ確認
ret, first_frame = cap.read()
if not ret:
    print('動画の読み込みに失敗しました')
    cap.release()
    exit()

# フレームサイズとグラフサイズの事前計算
frame_h, frame_w = first_frame.shape[:2]
frame_size = (frame_h, frame_w)
graph_w = frame_w // 2
graph_h = frame_h

# フレームポインタを先頭に戻す
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)

# データ履歴と移動平均バッファの初期化
feature_keys = ['ear', 'mar', 'eyebrow', 'smile', 'brow_down']
data_hist = {key: deque(maxlen=GRAPH_LENGTH) for key in feature_keys}
avg_buf = {key: deque(maxlen=WINDOW_SIZE) for key in feature_keys}

# タイムスタンプ管理
frame_cnt = 0
fps = cap.get(cv2.CAP_PROP_FPS) if cap.get(cv2.CAP_PROP_FPS) > 0 else 30

# 結果記録用
results_log = []
last_print_time = time.time()

# メイン処理
try:
    while True:
        cap.grab()
        ret, frame = cap.retrieve()
        if not ret:
            break

        # フレーム処理
        processed_frame, current_feat = process_frame(
            frame, landmarker, data_hist, avg_buf, frame_cnt, fps, frame_size
        )
        frame_cnt += 1

        # 1秒間隔でprint出力
        current_time = time.time()
        if current_time - last_print_time >= 1.0 and current_feat:
            if len(data_hist['ear']) > 0:
                result_text = (f'Frame {frame_cnt}: '
                             f'EAR={data_hist["ear"][-1]:.3f}, '
                             f'MAR={data_hist["mar"][-1]:.3f}, '
                             f'Eyebrow={data_hist["eyebrow"][-1]:.3f}, '
                             f'Smile={data_hist["smile"][-1]:.3f}, '
                             f'BrowDown={data_hist["brow_down"][-1]:.3f}')
                print(result_text)
                results_log.append(result_text)
                last_print_time = current_time

        # グラフ画像生成と結合
        if len(data_hist['ear']) > 1:
            graph_img = create_graph_image(data_hist, graph_w, graph_h)
            graph_img_bgr = cv2.cvtColor(graph_img, cv2.COLOR_RGB2BGR)
            combined = np.hstack([processed_frame, graph_img_bgr])
        else:
            # データが少ない場合は動画のみ表示
            blank = np.ones((graph_h, graph_w, 3), dtype=np.uint8) * 255
            combined = np.hstack([processed_frame, blank])

        cv2.imshow('Video', combined)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break
finally:
    cap.release()
    cv2.destroyAllWindows()
    landmarker.close()

    # 一時ファイルの削除
    if temp_file and os.path.exists(temp_file):
        os.remove(temp_file)

    # 結果をファイルに保存
    if results_log:
        with open('result.txt', 'w', encoding='utf-8') as f:
            for line in results_log:
                f.write(line + '\n')
        print('result.txtに保存しました')