iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Build on Google AI

Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化系列 第 11 篇

Flutter : Google MediaPipe Face Landmarker x App 開發實務

  • 分享至 

  • xImage
  •  

一、前言

前幾天,我們見識了俠客 Face Landmarker 的絕技三式,也把他請進了府裡。
但請得進門,不代表用得順手。

俠客入府第一天,老爺就交代了任務:
「幫我看看長輩的嘴有沒有張開、有沒有嘟起來、臉頰有沒有鼓起來!」

結果戰報送回來了,問題也跟著來了:

  • 滿紙都是 x、y、z,哪一點才是嘴角?
  • 叫他盯著臉頰,他回報:「沒看到動靜。」
  • 問他「左邊還是右邊?」他的左邊,好像跟我們的左邊不太一樣……

江湖傳言和實際交手,總有些落差。

今天就從「健口動一動」App 的開發經驗出發,看看和這位俠客磨合時,
踩過哪些坑,又是怎麼填起來的!


二、實務1:Face Landmarks 臉部特徵點

(一)模型輸出長什麼樣

以 iOS 為例,結果型別是 FaceLandmarkerResult,特徵點放在 faceLandmarks:

// [[NormalizedLandmark]]:兩層陣列
result.faceLandmarks

// 外層 [0]:第 0 張臉(numFaces = 1,只有這一張)
result.faceLandmarks[0]

// 內層 [1]:這張臉的 1 號點(鼻尖)→ x、y、z
// 陣列位置就是點的編號(0~477),共 478 個臉部特徵點
result.faceLandmarks[0][1]

// 下面程式碼的 landmarks,都是指這張臉的 478 個點
let landmarks = result.faceLandmarks[0]

要注意:直接 print 時,看到的是記憶體位址。 
NormalizedLandmark 是 Objective-C 類別,沒有自訂 description,
所以 console 會顯示成這樣:

[<MPPNormalizedLandmark: 0x12cf1c6c0>, <MPPNormalizedLandmark: 0x12cf1c630>]

要自己把 x、y、z 取出來:

for (i, p) in landmarks.prefix(3).enumerated() {
    print(String(format: "landmark[%ld] x=%.4f y=%.4f z=%+.4f",
                 i, p.x, p.y, p.z))
}
faceLandmarks: 1 張臉 × 478 個點
landmark[0] x=0.4697 y=0.7231 z=-0.0650
landmark[1] x=0.4623 y=0.6682 z=-0.0900
landmark[2] x=0.4658 y=0.6875 z=-0.0544
  • x、y:0~1 的比例,分別除以影像的寬和高,乘回去才是像素。
  • z:深度,以頭部中心為原點,數值越小越靠近鏡頭。

Flutter 專案的小提醒:
iOS 17 以後用無線偵錯時,flutter run 只顯示 Dart 端的 log,原生端的 NSLog 看不到;從 Xcode 執行 ios/Runner.xcworkspace,就能在 Xcode 的 console 看到。

(二)用編號取出特徵點 (Face Landmarks)

478 個點沒有名字,只有編號,而編號就是陣列的位置。
模型每一影格(frame)都會交出完整的 478 個點,順序固定:
291 號永遠是同一個嘴角,會變的只有它這一影格的 x、y、z。

就像點名:喊 291 號,站起來的永遠是同一位同學,只是每次站的位置不同。
所以不需要拿座標去找編號,流程是反過來的:

  1. 查官方的 canonical face mesh 編號圖,找到部位的編號(例如嘴角是 61、291)。
  2. 用編號當索引取點:landmarks[291]。
  3. 拿到這一影格的 x、y,乘上影像的寬、高,就是像素位置。

健口操用到的幾個點:

編號 部位 拿來做什麼
61、291 兩側嘴角 描出嘴唇輪廓、舌壓訓練的箭頭起點
13、14 上、下唇內緣 嘴巴張開幅度
234、454 兩側臉緣 臉寬,決定標記要畫多大
93、323 兩側耳前 耳下腺的按摩位置
172、397 兩側下顎角 顎下腺的按壓點
1、152 鼻尖、下巴 臉的上下方向、舌下腺的位置

程式裡把要用的編號寫成陣列,照順序點名:

// 外唇 20 點,照輪廓順序排,取出來就能直接連成一圈
static let outerLip = [61, 146, 91, 181, 84, 17, 314, 405, 321, 375,
                       291, 409, 270, 269, 267, 0, 37, 39, 40, 185]

for index in contourIndices {     // 478 點只取用得到的 87 點
    contour.append(landmarks[index].x)
    contour.append(landmarks[index].y)
}

(三)實際怎麼用:把按摩位置標在長輩臉上

唾液腺按摩採計時引導,
但「按哪裡」是用特徵點即時標在長輩臉上的,臉一移動,標記就跟著走。

三個按摩位置由特徵點推算出來:

按摩位置 用到的點 怎麼算
耳下腺 耳前 93/323、嘴角 61/291 從耳前往同一側的嘴角走 20%
顎下腺 下顎角 172/397、下巴 152、鼻尖 1 在下顎角到下巴的連線上取 4 點,再往「鼻尖→下巴」方向下移一點
舌下腺 下巴 152、鼻尖 1 從下巴沿「鼻尖→下巴」方向再往下 15%

以耳下腺為例:

/// 耳下腺:耳前錨點往同一邊的嘴角走兩成。兩邊各一個。
static List<Offset> parotidPoints(FaceFrame frame) {
  // 外唇 20 點,[0] 是 61、[10] 是 291
  final outer = frame.outerLip;
  final ears = [frame.earA, frame.earB];   // 93、323
  return [
    for (final ear in ears.cast<Offset>())
      // 往近的那個嘴角走 20%
      Offset.lerp(ear, _nearer(ear, outer[0], outer[10]), 0.2)!,
  ];
}
  • 只用「從 A 往 B 走幾成」的比例,不用絕對長度:這樣就不必處理 x、y 基準不同的問題,換算到畫面上也不會變形。
  • 每一格重新計算:長輩轉頭、前後移動,標記都會貼在正確的位置。

小提醒:x、y 的基準不同。如果要算的是距離(例如張嘴幅度),
就得先換算:App 的影格是 480×640,不換算的話,垂直距離會少算 25%,頭歪 10 度也會被算成約 7.5 度。

(四)點被遮住時,會輸出什麼?

長輩做唾液腺按摩時,手指會碰到臉。被手擋住的點,模型還會給座標嗎?

用官方測試圖,拿灰色方塊當作「手」,從側邊遮住部分的臉:

遮擋 輸出點數 被遮住的嘴角(點291)偏移 看得見那側的 mouthSmileRight
沒遮(原圖) 478 — 0.55
遮住 40% 的臉 478 臉寬的 3% 0.21
遮住 50% 的臉 478 臉寬的 10% 0.01
從另一側遮住 60% 0(偵測不到臉) — —
  • 被遮住的點照常輸出座標,但那是模型依臉的其他部分推測出來的,遮得越多偏得越遠。
  • 看得見的那一側也會受影響:模型是一次推算整張臉,半邊被遮住時,連看得見那側的微笑都從 0.55 掉到接近 0。
  • 沒辦法知道哪個點被遮住:NormalizedLandmark 雖然有 visibility、presence 欄位,但 Face Landmarker 不會填值,iOS 上始終是 nil;在 Face、Hand、Pose 三個 Landmarker 裡,只有 Pose Landmarker 會填。

所以健口動一動裡需要用手碰臉的動作(例如唾液腺按摩),都採用計時引導,不靠相機判定。


三、實務2:Blendshapes 表情係數

(一)模型輸出長什麼樣

// 外層:每張臉一組(numFaces = 1,只有 [0])
result.faceBlendshapes

// 外層 [0]:第 0 張臉的表情係數
result.faceBlendshapes[0]

// .categories:這張臉的 52 個表情係數
// 下面程式碼的 categories,都是指這 52 個
let categories = result.faceBlendshapes[0].categories

// 內層 [44]:第 44 個係數(數值取自官方測試圖)
let c = categories[44]

c.index          // 44
c.categoryName   // "mouthSmileLeft"
c.score          // 0.65(0~1,越大代表動作越明顯)
c.displayName    // nil:有這個欄位,但 Face Landmarker 不會填值

一樣,直接 print 只看得到 [<MPPClassifications: 0x11465d580>]。
取出來之後長這樣:

faceBlendshapes: 1 組 × 52 個類別
category index=0 name=_neutral score=0.0000
category index=1 name=browDownLeft score=0.0137
category index=2 name=browDownRight score=0.0114
…(其餘 49 個省略)
全部 52 個裡有 tongueOut 嗎?false

用的時候看 categoryName(displayName 是 nil)。
App 只留下嘴巴、下顎、臉頰相關的:

// mouthKeys:嘴巴、下顎、臉頰相關的名稱,約 30 個
var shapes: [String: Double] = [:]
for category in categories {
    guard let name = category.categoryName,
          Self.mouthKeys.contains(name) else { continue }
    shapes[name] = Double(category.score)
}

(二)兩個坑:臉頰和舌頭

在 iPhone 實機上,每個動作維持約 7~12 秒(每 0.5 秒記一筆):

動作 jawOpen mouthPucker cheekPuff
張大嘴 0.40–0.61 0.00–0.03 0.00
嘟嘴 0.00–0.01 1.00 0.00
用力鼓雙頰 0.03–0.05 0.82–0.97 0.00
伸舌頭 0.39–0.73 0.00–0.03 0.00

1. 臉頰:cheekPuff 始終是 0

張嘴、嘟嘴時,對應的係數都有明顯的反應;用力鼓起雙頰時,cheekPuff 則一直維持在 0.00。GitHub 上也有開發者觀察到類似的情況(#4436)。

從模型的輸入可以找到一些線索:

  • blendshape 模型的輸入是 [1, 146, 2],也就是 146 個特徵點的 x、y,不包含影像和 z。
  • 鼓頰主要是往鏡頭的方向鼓起,x、y 的變化很小。
  • 官方模型卡在 cheekPuff 旁邊註明「blendshape predicted by the FaceMesh model」,看起來這一項原本就不是由這個 blendshape 模型負責。

同一次實測,改用特徵點自己量:

  • 輪廓寬度(下半臉 ÷ 耳前臉寬):鼓雙頰比放鬆時寬 0.6%,嘟嘴時也寬了 0.4%。
  • 臉頰點的 z(相對鼻尖):鼓頰時往鏡頭靠近 0.001,跟點位本身的抖動差不多。

這些變化都還在雜訊的範圍內,目前不足以當作判斷臉頰鼓起的依據。

2. 舌頭:tongueOut 不在表情係數

上面 console 的最後一行可以看到,52 個係數裡沒有 tongueOut,第 0 個是 _neutral。實機伸舌頭時,也只有 jawOpen 升高,數值跟張大嘴差不多。

往模型檔裡看,FaceMesh 模型(face_landmarks_detector.tflite)本身帶有一個 tongue_out 輸出,說明寫著「Score of whether tongue is out of mouth.」。

也就是說,舌頭分數的輸出其實在 FaceMesh 模型裡,只是 Face Landmarker Task 沒有把它接出來。GitHub 上也有開發者回報 tongueOut 不在輸出裡(#4403)。

因此,健口動一動目前把臉頰和舌頭的動作都改成「計時引導」,用大字和倒數陪長輩完成。


四、實務3:左右判定

(一)結論

  • Left 指的是「輸入影像裡那張臉自己的左邊」。
  • 送進模型的影格沒有鏡像 → mouthSmileLeft 就是使用者本人的左嘴角。
  • 影格鏡像後 → 左右對調,mouthSmileLeft 變成使用者的右嘴角。
  • 特徵點編號也一樣:291 永遠在 61 的右邊(畫面上),跟著畫面裡的臉走。

官方的 iOS、Android 範例會先把前鏡頭影格鏡像,再送進模型(isVideoMirrored = true、postScale(-1f, 1f, …)),這樣點位可以直接對上鏡像預覽,很適合做疊圖。

如果 App 需要分辨使用者本人的左右(例如「舌頭頂左邊臉頰」),
就要留意:鏡像之後拿到的 Left、Right,會和使用者本人的左右相反。

健口動一動的做法是把兩條路分開設定:

// CameraSession:送進模型的影格「不」鏡像
connection.isVideoMirrored = false

// PreviewView:預覽畫面鏡像,長輩看到的像在照鏡子
connection.isVideoMirrored = true

相對地,把點疊到預覽上時,x 要換算成 1 - x。

(二)實測數據

靜態照片沒有「只動一邊」的表情,所以把官方測試圖畫面右側(291 那一側)人工變形,再把整張圖左右翻轉,各自和同方向、沒變形的原圖比較:

變形 方向 Left Right
畫面右側嘴角往上拉(mouthSmile) 原圖 +0.192 +0.046
左右翻轉後 −0.051 +0.121
畫面右側眼睛壓窄(eyeSquint) 原圖 +0.368 −0.099
左右翻轉後 −0.081 +0.426

同一處變形,翻轉前升高的是 Left,翻轉後換成 Right。

再用 iPhone 實機驗一次(送進模型的影格不鏡像,數值是平均):

使用者本人 eyeBlinkLeft eyeBlinkRight
閉左眼 0.60 0.42
閉右眼 0.52 0.63

閉單眼時另一眼也會跟著半閉,所以差距不大,但方向一致:
閉左眼那段,16 筆裡每一筆都是 eyeBlinkLeft 比較高。

(三)提 issue 給官方

目前官方文件和模型卡還沒有說明 *Left/*Right 以哪一側為準。
模型的行為本身很合理,補上一句說明,也許能讓開發者少走一些彎路,
所以嘗試提 Documentation issue:
建議在指南加一句:
*Left/*Right 指的是輸入影像裡那張臉自己的左右;鏡像輸入會讓兩者對調。
並附上重現腳本、實測數據,以及 Android、iOS、Web 範例做法不一致的連結。

Issue 連結:#6368


五、小結

今天我們從實務出發,聚焦 Face Landmarker 的三件事:

  • Face Landmarks:模型只給 478 個沒有名字的點,要靠編號,才知道誰是嘴角、誰是下唇。
  • Blendshapes:52 個表情係數不是每個都能用,臉頰和舌頭要特別留意,可能需要找替代方案。
  • 左右判定:Left 是畫面裡那張臉自己的左邊,鏡像會讓左右對調;要分使用者本人的左右,送進模型的影格就不要鏡像。實測數據整理好,也提交建議 issue 給官方了。

文件告訴我們這位俠客會什麼,實測才告訴我們他在什麼情況下會失手。

長輩使用的 App,這一點特別重要:
長輩在使用上可能沒辦法完全配合模型,所以要設法讓程式簡便、貼近長輩。

Google MediaPipe Face Landmarker 系列就到這裡告一段落,謝謝一路陪伴的你!


六、預告

健口操的大部分功能已經完成,接下來會持續除錯、優化,並美化 UI。
目前還剩兩項要攻克:繞口令,以及發音辨識的穩定度。

還記得 Day1 提到做 prototype 時遇到的挑戰嗎?

當時選用 sherpa-onnx,反覆調整模型與參數權重,
但發現「怕/踏/卡/啦」這四個單音節的辨識始終不太穩定。

這次,我打算請另一位高手出馬:Google Cloud Speech-to-Text。

明天就從認識 Google Cloud Speech-to-Text 開始,
看看它能不能幫繞口令和發音辨識找到解方!

感謝有緣看到這裡的你~
希望佛菩薩也祝福你:🌟平安幸福 健康順遂🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️


上一篇
少年,我看你面相不錯。你聽過 Google MediaPipe Face Landmarker 嗎?(下)
下一篇
雲端語音辨識:Google Cloud Speech to Text
系列文
Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言