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次元ランドマークと表情係数を用いて顔の動きを解析する。具体的には、目の開閉(瞬き)、口の開き具合、眉の動き、笑顔の度合いなどの表情変化をリアルタイムで数値化し、時系列データとして可視化する。これは人間の表情という非言語的コミュニケーションを機械が理解可能な形式に変換する能力である。
主要技術
- MediaPipe Face Landmarkerは、Googleが開発した顔追跡技術である。この技術は、単一のRGB画像から478個の3次元顔ランドマークを推定し、52個のブレンドシェイプ係数を出力する[1]。内部的には、顔検出と顔メッシュ推定の2段階で処理を行い、軽量なニューラルネットワークモデルを使用することで、モバイルデバイスでもリアルタイム処理を実現している[2]。
- 移動平均フィルタは、時系列データのノイズ除去に使用される信号処理技術である。本プログラムでは、検出された特徴量の時間的な変動を平滑化し、瞬間的なノイズや検出誤差の影響を軽減している。ウィンドウサイズ内のデータポイントの算術平均を計算することで、より安定した特徴量の推移を得ている[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に保存しました')