generated from jphacks/JP_sample
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseFacialAnalysis.ts
More file actions
352 lines (297 loc) · 11 KB
/
useFacialAnalysis.ts
File metadata and controls
352 lines (297 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
import type {
FaceLandmarker,
FaceLandmarkerResult,
} from "@mediapipe/tasks-vision";
import { useCallback, useEffect, useRef, useState } from "react";
export interface FacialMetrics {
isSmiling: boolean; // 笑顔かどうか
smileIntensity: number; // 笑顔の強さ (0-1)
isLookingAtTarget: boolean; // 対象(アバター)を見ているか
gazeScore: number; // 視線スコア (0-1, 1が最適)
gazeVertical: "up" | "down" | "center"; // 視線の上下
mouthCornerLeft: number; // 左口角の高さ
mouthCornerRight: number; // 右口角の高さ
}
export interface GazeTarget {
x: number; // 画面上のX座標(0-1の範囲、左が0、右が1)
y: number; // 画面上のY座標(0-1の範囲、上が0、下が1)
}
export interface UseFacialAnalysisReturn {
metrics: FacialMetrics | null;
isAnalyzing: boolean;
error: Error | null;
startAnalysis: (
videoElement: HTMLVideoElement,
gazeTarget?: GazeTarget,
) => Promise<void>;
stopAnalysis: () => void;
setGazeTarget: (target: GazeTarget) => void;
}
const DEFAULT_METRICS: FacialMetrics = {
isSmiling: false,
smileIntensity: 0,
isLookingAtTarget: false,
gazeScore: 0,
gazeVertical: "center",
mouthCornerLeft: 0,
mouthCornerRight: 0,
};
/**
* MediaPipe Face Landmarkerを使用した表情分析フック
*/
export function useFacialAnalysis(): UseFacialAnalysisReturn {
const [metrics, setMetrics] = useState<FacialMetrics | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<Error | null>(null);
const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
const animationFrameRef = useRef<number | null>(null);
const videoElementRef = useRef<HTMLVideoElement | null>(null);
// 最後に渡したタイムスタンプ(マイクロ秒)を保持し、必ず単調増加にする
const lastTimestampRef = useRef<number>(0);
const lastAnalysisTimeRef = useRef<number>(0); // 最後に分析した時刻
const gazeTargetRef = useRef<GazeTarget>({ x: 0.25, y: 0.5 }); // デフォルト: 左側中央(アバター位置)
// フレームレート制御:333ms = 3fps(パフォーマンス最適化)
const ANALYSIS_INTERVAL_MS = 333;
// MediaPipe Face Landmarkerの初期化
const initializeFaceLandmarker = async () => {
try {
const { FaceLandmarker, FilesetResolver } = await import(
"@mediapipe/tasks-vision"
);
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm",
);
const faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
delegate: "GPU",
},
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true,
runningMode: "VIDEO",
numFaces: 1,
});
faceLandmarkerRef.current = faceLandmarker;
console.log("MediaPipe Face Landmarker 初期化完了");
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
console.error("Face Landmarker初期化エラー:", error);
throw error;
}
};
// 笑顔判定:口角の位置から計算
const calculateSmile = (
landmarks: { x: number; y: number; z: number }[],
): { isSmiling: boolean; intensity: number } => {
// 口角のランドマークインデックス
const LEFT_MOUTH_CORNER = 61; // 左口角
const RIGHT_MOUTH_CORNER = 291; // 右口角
const UPPER_LIP_CENTER = 13; // 上唇中央
const LOWER_LIP_CENTER = 14; // 下唇中央
const leftCorner = landmarks[LEFT_MOUTH_CORNER];
const rightCorner = landmarks[RIGHT_MOUTH_CORNER];
const upperLip = landmarks[UPPER_LIP_CENTER];
const lowerLip = landmarks[LOWER_LIP_CENTER];
// 口の中心のY座標
const mouthCenterY = (upperLip.y + lowerLip.y) / 2;
// 口角が口の中心より上にあれば笑顔
const leftLift = mouthCenterY - leftCorner.y;
const rightLift = mouthCenterY - rightCorner.y;
// 笑顔の強さ(0-1)
const intensity = Math.max(0, Math.min(1, (leftLift + rightLift) * 10));
// 笑顔判定(閾値: 0.3以上)
const isSmiling = intensity > 0.3;
return { isSmiling, intensity };
};
// 視線判定:顔の向きとターゲット位置から計算
const calculateGaze = (
landmarks: { x: number; y: number; z: number }[],
): {
isLookingAtTarget: boolean;
gazeScore: number;
verticalDirection: "up" | "down" | "center";
} => {
// 顔の主要ランドマーク
const NOSE_TIP = 1;
const LEFT_EYE = 33;
const RIGHT_EYE = 263;
const noseTip = landmarks[NOSE_TIP];
const leftEye = landmarks[LEFT_EYE];
const rightEye = landmarks[RIGHT_EYE];
// 目の中心
const eyeCenterX = (leftEye.x + rightEye.x) / 2;
const eyeCenterY = (leftEye.y + rightEye.y) / 2;
// ターゲット(アバター)の位置
const target = gazeTargetRef.current;
// 顔の向きを計算(鼻と目の中心の相対位置から)
const faceDirectionX = noseTip.x - eyeCenterX;
const faceDirectionY = noseTip.y - eyeCenterY;
// ターゲットへの方向を計算
// カメラ座標系: 左が0、右が1なので、左を見るためには顔を右に向ける必要がある
const targetDirectionX = eyeCenterX - target.x;
const targetDirectionY = target.y - eyeCenterY;
// 顔の向きとターゲット方向の一致度
const horizontalMatch = 1 - Math.abs(faceDirectionX - targetDirectionX) * 3;
const verticalMatch = 1 - Math.abs(faceDirectionY - targetDirectionY) * 4;
// 視線スコア(0-1、1がターゲットを見ている)
const gazeScore = Math.max(
0,
Math.min(1, (horizontalMatch + verticalMatch) / 2),
);
// ターゲットを見ている判定(閾値: 0.6以上)
const isLookingAtTarget = gazeScore > 0.6;
// 視線の上下方向を判定
const verticalDelta = faceDirectionY - targetDirectionY;
const verticalDirection =
verticalDelta > 0.05 ? "down" : verticalDelta < -0.05 ? "up" : "center";
return { isLookingAtTarget, gazeScore, verticalDirection };
};
// フレームごとの分析処理
const analyzeFrame = async (timestamp: number) => {
if (
!faceLandmarkerRef.current ||
!videoElementRef.current ||
!isAnalyzing
) {
return;
}
// フレームレート制御:指定間隔以内なら次のフレームへ
const timeSinceLastAnalysis = timestamp - lastAnalysisTimeRef.current;
if (timeSinceLastAnalysis < ANALYSIS_INTERVAL_MS) {
animationFrameRef.current = requestAnimationFrame(analyzeFrame);
return;
}
lastAnalysisTimeRef.current = timestamp;
try {
const video = videoElementRef.current;
// ビデオが準備できているか確認
if (video.readyState < 2) {
animationFrameRef.current = requestAnimationFrame(analyzeFrame);
return;
}
// MediaPipe Tasks の detectForVideo はタイムスタンプに単調増加の整数(マイクロ秒)を期待する
// requestAnimationFrame の timestamp はミリ秒(小数)なのでマイクロ秒に変換し、
// 前回より小さい/等しい場合は前回+1 を渡すことで単調増加を保証する。
const proposedTimestampMicro = Math.round(timestamp * 1000); // ms -> μs
let usedTimestamp = proposedTimestampMicro;
if (usedTimestamp <= lastTimestampRef.current) {
usedTimestamp = lastTimestampRef.current + 1;
}
lastTimestampRef.current = usedTimestamp;
// Face Landmarkerで顔のランドマークを検出
const result: FaceLandmarkerResult =
faceLandmarkerRef.current.detectForVideo(video, usedTimestamp);
if (result.faceLandmarks && result.faceLandmarks.length > 0) {
const landmarks = result.faceLandmarks[0];
// 笑顔分析
const smileData = calculateSmile(landmarks);
// 視線分析
const gazeData = calculateGaze(landmarks);
// 口角の位置
const leftCorner = landmarks[61];
const rightCorner = landmarks[291];
// メトリクスを更新(変化があった場合のみ更新してパフォーマンス改善)
setMetrics((prev) => {
const newMetrics = {
isSmiling: smileData.isSmiling,
smileIntensity: smileData.intensity,
isLookingAtTarget: gazeData.isLookingAtTarget,
gazeScore: gazeData.gazeScore,
gazeVertical: gazeData.verticalDirection,
mouthCornerLeft: leftCorner.y,
mouthCornerRight: rightCorner.y,
};
// 大きな変化がある場合のみ更新(不要な再レンダリングを防ぐ)
// 閾値を0.1に引き上げてさらに更新頻度を削減
if (
!prev ||
prev.isSmiling !== newMetrics.isSmiling ||
Math.abs(prev.smileIntensity - newMetrics.smileIntensity) > 0.1 ||
prev.isLookingAtTarget !== newMetrics.isLookingAtTarget ||
Math.abs(prev.gazeScore - newMetrics.gazeScore) > 0.1
) {
return newMetrics;
}
return prev;
});
} else {
// 顔が検出されない場合はデフォルト値
setMetrics(DEFAULT_METRICS);
}
} catch (err) {
// TensorFlow LiteのINFOログは無視する
const errorMessage = err instanceof Error ? err.message : String(err);
if (
!errorMessage.includes("INFO:") &&
!errorMessage.includes("Created TensorFlow")
) {
console.error("フレーム分析エラー:", err);
}
}
// 次のフレームを処理
animationFrameRef.current = requestAnimationFrame(analyzeFrame);
};
// ターゲット位置の設定
const setGazeTarget = (target: GazeTarget) => {
gazeTargetRef.current = target;
console.log("視線ターゲット更新:", target);
};
// 分析開始
const startAnalysis = async (
videoElement: HTMLVideoElement,
gazeTarget?: GazeTarget,
) => {
try {
setError(null);
videoElementRef.current = videoElement;
// ターゲット位置が指定されている場合は更新
if (gazeTarget) {
gazeTargetRef.current = gazeTarget;
}
// Face Landmarkerが初期化されていない場合は初期化
if (!faceLandmarkerRef.current) {
await initializeFaceLandmarker();
}
setIsAnalyzing(true);
// 分析ループ開始
animationFrameRef.current = requestAnimationFrame(analyzeFrame);
console.log("表情分析開始(ターゲット位置:", gazeTargetRef.current, ")");
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
setIsAnalyzing(false);
console.error("分析開始エラー:", error);
}
};
// 分析停止
const stopAnalysis = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
setIsAnalyzing(false);
setMetrics(null);
videoElementRef.current = null;
console.log("表情分析停止");
}, []);
// クリーンアップ
useEffect(() => {
return () => {
stopAnalysis();
if (faceLandmarkerRef.current) {
faceLandmarkerRef.current.close();
faceLandmarkerRef.current = null;
}
};
}, [stopAnalysis]);
return {
metrics,
isAnalyzing,
error,
startAnalysis,
stopAnalysis,
setGazeTarget,
};
}