昨天我們已經確立了健口動一動 App 功能的開發清單與實作方向。
其中,
健口操的嘴型動作是否正確,會由 MediaPipe Face Landmarker 提供臉部關鍵點與表情特徵,再由 App 根據嘴巴開合比例、寬度及動作規則進行判斷。
臉部網格偵測究竟是什麼? 是偵測人臉在哪裡嗎?
讓我們往下看吧!
| 比較項目 | 臉部偵測(Face Detection) | 臉部網格偵測(Face Mesh Detection) |
|---|---|---|
| 核心問題 | 畫面中有沒有臉?臉在哪裡? | 這張臉的形狀與表情長什麼樣子? |
| 主要輸出 | 邊界框、信心分數、少量關鍵點 | 478 個正規化臉部關鍵點,包含 x、y 與相對深度 z。 |
| 三角網格 | 通常沒有 | 將相鄰的 3D 關鍵點依固定拓撲連接成三角面,形成臉部網格(Face Mesh) |
| 資訊密度 | 低 | 高 |
| 運算成本 | 低 | 較高 |
| 典型用途 | 人臉計數、自動對焦、裁切人臉、隱私打碼、人臉辨識的前處理 | AR 特效、Avatar 驅動、臉部貼圖、表情與動作判斷 |
從健口動一動App的開發來看,臉部偵測(Face Detection)確認長輩有沒有對準鏡頭、臉部網格偵測(Face Mesh Detection)確認嘴部、臉部的動作。另外,兩者都不會進行身分識別:它們只判斷「有沒有臉」與「臉怎麼動」,不會辨認「這是誰」。
剛剛介紹的臉部偵測和臉部網格偵測,其實是上下游的關係。
以 MediaPipe Face Landmarker 為例,實際流程是:

實務上,有一點發現:
健口動一動的專案裡只有建立一個FaceLandmarker,沒有另外呼叫FaceDetector
但圖中的階段 1 確實存在,只是被完整封裝在 Face Landmarker Task 內部,並在需要定位或重新定位人臉時執行。
static var contourIndices: [Int] {
outerLip + innerLip + faceEdges + upperCheekA + upperCheekB + lowerCheekA + lowerCheekB + anchors + ovalA + ovalB
}
var contour = [Float]()
contour.reserveCapacity(Self.contourIndices.count * 2)
for index in Self.contourIndices {
contour.append(Float(landmarks[index].x))
contour.append(Float(landmarks[index].y))
}
extension FaceMeshPlugin: FaceLandmarkerServiceDelegate {
func faceLandmarkerService(_ service: FaceLandmarkerService, didProduce frame: FaceFrame?) {
DispatchQueue.main.async {
guard let sink = self.eventSink else { return }
guard let frame = frame else {
sink(["hasFace": false])
return
}
// 走 Float32List 而不是 List<Double>。
// 84 個浮點數如果包成 Dart List,每秒 30 次要配置 84 個 NSNumber;
// typed data 是整塊記憶體直接過去,差距在持續串流下很明顯。
let contourData = frame.contour.withUnsafeBufferPointer { Data(buffer: $0) }
sink([
"hasFace": true,
"blendshapes": frame.blendshapes,
"yaw": frame.yaw,
"rollDegrees": frame.rollDegrees,
"mouthOpenRatio": frame.mouthOpenRatio,
"mouthWidthRatio": frame.mouthWidthRatio,
"contour": FlutterStandardTypedData(float32: contourData),
"imageWidth": frame.imageWidth,
"imageHeight": frame.imageHeight
])
}
}
}
4.Dart 接住
factory FaceFrame.fromMap(Map<Object?, Object?> map) {
if (map['hasFace'] != true) return const FaceFrame(hasFace: false);
final raw = (map['blendshapes'] as Map?) ?? const {};
return FaceFrame(
hasFace: true,
blendshapes: {
for (final entry in raw.entries)
entry.key as String: (entry.value as num).toDouble(),
},
yaw: (map['yaw'] as num?)?.toDouble() ?? 0,
rollDegrees: (map['rollDegrees'] as num?)?.toDouble() ?? 0,
mouthOpenRatio: (map['mouthOpenRatio'] as num?)?.toDouble() ?? 0,
mouthWidthRatio: (map['mouthWidthRatio'] as num?)?.toDouble() ?? 0,
contour: map['contour'] as Float32List?,
imageSize: Size(
((map['imageWidth'] as num?) ?? 480).toDouble(),
((map['imageHeight'] as num?) ?? 640).toDouble(),
),
);
}
5.繪製
void _paintPoints(Canvas canvas, FaceMeshProjection projection) {
final halo = Paint()..color = Colors.black.withValues(alpha: 0.45);
for (final group in kLandmarkGroups) {
final dot = Paint()..color = group.color;
for (final point in group.pointsOf(frame)) {
final at = projection.project(point);
canvas.drawCircle(at, 4.0, halo);
canvas.drawCircle(at, 2.4, dot);
}
}
}
怎麼知道要畫在哪?
靠三個數字:影格的真實像素(原生端給)、painter 在螢幕上的大小(Flutter 給)、點的 0~1 相對位置(MediaPipe 給)。MediaPipe 不知道使用者螢幕多大,只能給相對位置,所以這段換算得自己補——三個數字到齊就算得出來。
今天我們一起認識了「Face Detection vs. Face Mesh Detection」
知道臉部偵測和臉部網格偵測的差異及上下游關係,
實作繪製臉部關鍵點,看了從原生iOS橋接到Flutter的概略流程。
明天就讓我們進一步認識 Google MediaPipe Face Landmarker !
感謝有緣看到這的你~
希望佛菩薩也祝福你:🌟平安健康 開心幸福🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️