想像一下:完全不需要摸滑鼠與鍵盤,只要在 Webcam 前伸出食指就能滑順地移動游標、握緊拳頭就能點擊與拖曳視窗、伸出兩根手指就能滾動網頁,甚至快速向左或向右揮手就能瞬間切換 Windows 虛擬桌面!
這就是 gesture-control 專案的起點。身為 Rust 52 Projects 挑戰的一部分,我希望嘗試用 Rust + OpenCV 5 DNN + MediaPipe ONNX 打造一個無鎖、低延遲且完全執行於背景的原生手勢作業系統控制器。
坦白說,這套系統絕對離商業級的硬體產品(例如 Vision Pro 或立體深度感測器)還有一段距離,單眼 Webcam 亦有其物理上的局限性;但作為一個學習 Rust 跨語言 FFI、AI 視覺推論與時序演算法的實驗專案,它的表現已經足夠令人驚喜且樂趣十足。
這篇文章將全面介紹 gesture-control 的系統架構與實現細節,包含兩階段 AI 視覺推論管線、自動左手翻轉推論 (Left-Hand Fallback)、時序追蹤與遲滯投票機制 (Hysteresis Voting)、360 度旋轉無關幾何算子,以及 Enigo 原生 OS 輸入模擬!
專案核心特色
- 高精度 AI 3D 骨骼追蹤:基於 MediaPipe Palm Detection (SSD) 與 21-Landmark 3D Hand Pose Estimator ONNX 模型。
- 自動左手翻轉推論 (Auto Left-Hand Fallback):解析模型的 3 層輸出張量(含 Layer 2 右手性分數),利用雙向推論解決深度學習模型常見的「右手偏見(Right-hand Bias)」。
- 時序追蹤與遲滯投票 (Temporal Tracking & Hysteresis Voting):採用貪婪歐式距離配對與 $[-30, +30]$ 票數滑動視窗,消除左右手標籤閃爍與同手重複框(Ghost Hands)。
- 360 度旋轉無關幾何算子:基於手腕相對歐式距離比較,不受手掌旋轉角度影響。
- 眾數去顫濾波 (Mode-Based Debouncing):統計歷史影格眾數,過濾過渡影格的誤觸。
- 無頭背景模式 (Headless Mode):校正完畢後可關閉所有 GUI 視窗,達成零 GUI 開銷背景執行。
系統整體架構:雙執行緒與管線解耦
為了保證游標移動的絕對順暢,專案採用多執行緒解耦架構:視訊解碼與 AI 推論 運作於獨立的 Vision 執行緒,而 OS 輸入模擬 則運作於主執行緒,兩者透過 Rust 標準庫的無鎖 Channel (std::sync::mpsc) 通訊。
在入口點 src/main.rs 中,執行緒的分工與生命週期管理簡潔明瞭:
fn main() -> Result<()> {
let cli = Cli::parse();
let show_preview = !cli.no_preview;
// 1. 建立跨執行緒 Channel
let (tx, rx) = std::sync::mpsc::channel();
// 2. 啟動視覺處理執行緒 (Vision Loop Thread)
let vision_handle = thread::spawn(move || {
if let Err(e) = vision::run_vision_loop(cli.camera, tx, show_preview) {
eprintln!("Error in vision thread: {:?}", e);
}
});
// 3. 在主執行緒運行 OS 輸入控制迴圈 (Controller Loop)
controller::run_controller_loop(rx)?;
// 4. 等待視覺執行緒安全結束
if let Err(e) = vision_handle.join() {
eprintln!("Failed to join vision thread: {:?}", e);
}
Ok(())
}核心技術一:MediaPipe 兩階段 AI 視覺推論管線
1. 掌心偵測與距離中心 NMS 抑制
手部追蹤的第一步是從 640×480 的視訊影格中定位手掌。MediaPipe 的 palm_detection_mediapipe_2023feb.onnx 模型接收 $192 \times 192$ 的輸入,在 2016 個預設 Anchor 上預測邊界框與 7 個手掌特徵點。
為了防止傳統 IoU NMS 在同隻手掌上殘留「一大一小」的重複候選框,我們在 src/palm.rs 中加入了一層基於歐式距離的掌心中心抑制:
// 若兩掌心中心距離小於現有框尺寸的 75%,判定為同一隻手並進行抑制
let mut detections: Vec<PalmDetection> = Vec::new();
for det in raw_detections {
let mut duplicate = false;
for existing in &detections {
let dx = existing.center.x - det.center.x;
let dy = existing.center.y - det.center.y;
let dist = (dx * dx + dy * dy).sqrt();
let min_size = existing.bbox.width.min(existing.bbox.height) as f32;
if dist < min_size * 0.75 {
duplicate = true; // 抑制重複框
break;
}
}
if !duplicate {
detections.push(det);
}
}2. 手掌旋轉對齊與 Crop 仿射變換 (warp_affine)
在將手部 Crop 丟入 handpose_estimation_mediapipe_2023feb.onnx (224×224) 之前,模型要求手掌必須「旋轉朝上」。我們根據掌心角度建立 2×3 仿射旋轉矩陣,進行平移與旋轉對齊:
// 手掌旋轉對齊:建立 2x3 Affine Rotation Matrix
let angle_rad = (palm.angle as f64) * std::f64::consts::PI / 180.0;
let alpha = angle_rad.cos();
let beta = angle_rad.sin();
let mut rotation_matrix = Mat::new_rows_cols_with_default(2, 3, CV_64F, Scalar::all(0.0))?;
*rotation_matrix.at_2d_mut::<f64>(0, 0)? = alpha;
*rotation_matrix.at_2d_mut::<f64>(0, 1)? = beta;
*rotation_matrix.at_2d_mut::<f64>(0, 2)? = (1.0 - alpha) * palm_center.x as f64 - beta * palm_center.y as f64;
*rotation_matrix.at_2d_mut::<f64>(1, 0)? = -beta;
*rotation_matrix.at_2d_mut::<f64>(1, 1)? = alpha;
*rotation_matrix.at_2d_mut::<f64>(1, 2)? = beta * palm_center.x as f64 + (1.0 - alpha) * palm_center.y as f64;
// 對整張影像進行仿射旋轉與 Crop 截取
let mut rotated_image = Mat::default();
imgproc::warp_affine(&frame, &mut rotated_image, &rotation_matrix, Size::new(frame.cols(), frame.rows()), ...)?;3. 手法突破:解決「右模型偏見」的 Left-Hand Auto-Fallback
MediaPipe 的 Handpose 模型會輸出 3 個層級(Output Layers):
- Layer 0:21 個 3D 關節點座標 $[1, 63]$
- Layer 1:手部整體置信度分數 (Confidence Score)
- Layer 2:右手性置信度分數 (Right-Handedness Score)
由於訓練資料集的偏差,模型對右手的辨識率極高,但對左手容易產生低分或扭曲。為了改善這個問題,我們實作了雙向推論與 Layer 2 右手性評估:
let mut evaluate = |img: &Mat| -> Result<(f32, f32, Vector<Mat>)> {
let blob = float_mat.reshape_nd(1, &[1, 224, 224, 3])?;
net.set_input(&blob, "", 1.0, Scalar::default())?;
let mut out_layers = Vector::<Mat>::new();
net.forward(&mut out_layers, &net.get_unconnected_out_layers_names()?)?;
let score = *out_layers.get(1)?.at_2d::<f32>(0, 0)?; // Layer 1: 置信度
let handedness = *out_layers.get(2)?.at_2d::<f32>(0, 0)?; // Layer 2: 右手性得分
Ok((score, handedness, out_layers))
};
// 同時對原始 Crop 與水平翻轉 Crop (rgb_flipped) 進行推論
let (score_unflipped, handedness_unflipped, out_unflipped) = evaluate(&rgb)?;
let (score_flipped, handedness_flipped, out_flipped) = evaluate(&rgb_flipped)?;
// 模型永遠會給「看起來像右手」的影像更高的 Layer 2 分數
// 若翻轉後的影像分數更高,代表原始影像必定是「左手」!
let is_left_hand = handedness_flipped > handedness_unflipped;
// 若為左手,採用翻轉影像的推論結果,並將 X 座標鏡射還原 (224.0 - X)
透過這種雙向推論機制,系統對左手與右手的識別穩定度有了顯著提升!
核心技術二:時序追蹤與遲滯投票機制 (Hysteresis Voting)
單影格推論容易受到極端角度或運動模糊干擾,造成左右手標籤在相鄰影格間跳動。為此,我們設計了 TrackedHand 時序追蹤結構:
#[derive(Clone)]
struct TrackedHand {
keypoints: Vec<Point>,
score: f32,
center: Point,
left_votes: i32, // 遲滯投票計數器 [-30..+30]
is_left_hand: bool, // 最終鎖定的左右手標籤
missed_frames: usize, // 遮擋遺失影格補償計數
}1. 貪婪歐式距離配對 (Greedy Assignment)
每一影格收到新的 raw 偵測結果時,計算所有歷史追蹤器與新偵測點的掌心距離,按距離從小到大(門檻 $<400\text{px}$)進行貪婪配對,更新中心座標與關節點。
2. 遲滯投票機制 (Hysteresis Voting)
新的偵測若判定為左手,left_votes 加 1;若為右手則減 1(限制在 $[-30, +30]$ 範圍內)。只有當票數累積達到 $+20$ 或 $-20$ 的絕對壓倒性門檻時,才允許切換 is_left_hand 狀態:
if *is_left {
tracker.left_votes = (tracker.left_votes + 1).min(30);
} else {
tracker.left_votes = (tracker.left_votes - 1).max(-30);
}
// 遲滯門檻:防止單影格誤判造成的標籤閃爍
if tracker.left_votes >= 20 {
tracker.is_left_hand = true;
} else if tracker.left_votes <= -20 {
tracker.is_left_hand = false;
}3. 遮擋遺失補償 (Missed Frame Interpolation)
當手指快速揮過或被短暫遮擋時,未配對到的追蹤器會保留最多 5 個影格(約 80ms),避免手勢在中途斷開:
for (t_idx, tracker) in hand_trackers.iter_mut().enumerate() {
if !matched_tracker[t_idx] {
tracker.missed_frames += 1;
if tracker.missed_frames < 5 {
new_trackers.push(tracker.clone()); // 保持追蹤
}
}
}核心技術三:360 度旋轉無關幾何算子與眾數去顫
1. 旋轉無關的手指伸展判定
傳統演算法常假設手指朝上(檢查 $Y_{\text{tip}} < Y_{\text{pip}}$),手掌橫放即失靈。我們改用基於手腕(Wrist, Landmark 0)的相對歐氏距離比較:
$$\text{Extended} \iff \text{Dist}(\text{Wrist}, \text{Tip}) > \text{Dist}(\text{Wrist}, \text{PIP})$$let wrist = kps[0];
let index_ext = dist(wrist, kps[8]) > dist(wrist, kps[6]); // 8: 食指尖, 6: 食指 PIP 關節
let middle_ext = dist(wrist, kps[12]) > dist(wrist, kps[10]); // 12: 中指尖, 10: 中指 PIP 關節
let ring_ext = dist(wrist, kps[16]) > dist(wrist, kps[14]); // 16: 無名指尖, 14: 無名指 PIP 關節
let pinky_ext = dist(wrist, kps[20]) > dist(wrist, kps[18]); // 20: 小指尖, 18: 小指 PIP 關節
let extended_count = (index_ext as usize) + (middle_ext as usize) + (ring_ext as usize) + (pinky_ext as usize);由於歐氏距離具有旋轉不變性,無論手掌如何旋轉,伸展判斷皆相當穩定。
2. 眾數滑動視窗去顫 (Mode-Based Debouncing)
利用 VecDeque<usize> 維持最近 $N$ 影格(預設 $N=10$)的手指數量歷史,計算統計眾數(Mode)作為最終結果,有效過濾姿態切換瞬間的脈衝雜訊。
手勢狀態機與 OS 輸入模擬
在 src/controller.rs 中,控制執行緒接收到 GestureState 後,利用 enigo 與 Win32 API 執行系統操作:
| 手勢型態 | 手指伸展數 / 特徵條件 | 動作說明 | OS 模擬行為 (Enigo / Win32) |
|---|---|---|---|
| Hover | 1 根手指(食指) | 游標平滑移動 | 絕對座標定位至食指尖 ($X_{8}, Y_{8}$) |
| Click / Drag | 0 根手指(握拳 / Pinch) | 滑鼠點擊與拖曳 | 發送 Button::Left Press + 移動 |
| Scroll | 2 根手指(食指+中指) | 網頁高解析度滾動 | 計算兩指中心垂直位移發送 scroll |
| Wave Left | 4+ 根手指 + 快速向左揮 | 虛擬桌面左切 | 發送 Win + Ctrl + LeftArrow |
| Wave Right | 4+ 根手指 + 快速向右揮 | 虛擬桌面右切 | 發送 Win + Ctrl + RightArrow |
| Idle | 手部離開畫面 | 釋放按鍵防卡死 | 發送 Button::Left Release |
看這段在 src/controller.rs 裡的核心事件迴圈:
pub fn run_controller_loop(rx: Receiver<GestureState>) -> Result<()> {
let mut enigo = Enigo::new(&Settings::default())?;
let (screen_w, screen_h) = get_screen_size(); // Win32 GetSystemMetrics
let alpha = 0.20f32; // EMA 平滑係數
while let Ok(state) = rx.recv() {
match state {
GestureState::Hover { x, y } => {
if is_pressed {
enigo.button(Button::Left, Direction::Release)?;
is_pressed = false;
}
// EMA 平滑化算式
let target_x = alpha * x + (1.0 - alpha) * last_x;
let target_y = alpha * y + (1.0 - alpha) * last_y;
let px = (target_x * (screen_w as f32)) as i32;
let py = (target_y * (screen_h as f32)) as i32;
enigo.move_mouse(px, py, Coordinate::Abs)?;
last_x = target_x;
last_y = target_y;
}
GestureState::Click { x, y } => {
if !is_pressed {
enigo.button(Button::Left, Direction::Press)?;
is_pressed = true;
}
// 拖曳游標平滑移動...
}
GestureState::Scroll { dy } => {
scroll_accumulator += dy;
let scroll_clicks = scroll_accumulator.trunc() as i32;
if scroll_clicks != 0 {
enigo.scroll(-scroll_clicks, Axis::Vertical)?;
scroll_accumulator -= scroll_clicks as f32;
}
}
GestureState::WaveLeft => {
// 觸發 Windows 切換虛擬桌面快捷鍵
enigo.key(Key::Control, Direction::Press)?;
enigo.key(Key::Meta, Direction::Press)?;
enigo.key(Key::LeftArrow, Direction::Click)?;
enigo.key(Key::Meta, Direction::Release)?;
enigo.key(Key::Control, Direction::Release)?;
}
// ...
}
}
Ok(())
}而在 src/vision.rs 中,極速揮手(Wave Left / Right)則是透過時間差 $dt$ 計算平滑動態速度 $v_x$:
let dx = (palm_pt.x as f32) - last_palm_x;
last_palm_x = palm_pt.x as f32;
let velocity = dx / dt;
let alpha_vel = (dt * 10.0).clamp(0.0, 1.0);
velocity_x_smoothed = alpha_vel * velocity + (1.0 - alpha_vel) * velocity_x_smoothed;
if debounced_count >= 4 && elapsed_wave > 1.2 {
if velocity_x_smoothed < -450.0 {
current_state = GestureState::WaveLeft;
} else if velocity_x_smoothed > 450.0 {
current_state = GestureState::WaveRight;
}
}安裝、校正與 Headless 背景模式
1. 下載 MediaPipe ONNX 模型
專案提供 PowerShell 腳本自動下載 OpenCV Zoo 模型:
.\download_models.ps1這會將 palm_detection_mediapipe_2023feb.onnx 與 handpose_estimation_mediapipe_2023feb.onnx 下載至 models/ 目錄。
2. 互動校正
執行 cargo run 可開啟 GUI 視窗,即時調整 Trackbar 參數:
- Palm Conf %:掌心偵測門檻(預設 65%)。
- Pose Conf %:3D 骨骼點置信度門檻(預設 75%)。
- Debounce Frames:去顫視窗影格數(預設 10 幀)。
3. Headless 背景運行
參數校正完成後,加上 --no-preview 即可進行零 GUI 開銷的背景執行:
cargo run --release -- --no-preview品質保證:自動化測試與 FAR 驗證
專案包含基本的 #[cfg(test)] 自動化測試套件,輔助驗證功能:
- FAR (False Acceptance Rate) 假陽性測試:
測試純黑影像 (
test_far_blank_image)、靜態彩繪雜訊 (test_far_noise_image) 與幾何圖像 (test_far_geometric_shapes),確保系統在無手狀態下不致誤觸。
#[test]
fn test_far_blank_image() {
let mut net = dnn::read_net_def("models/palm_detection_mediapipe_2023feb.onnx").unwrap();
let frame = Mat::new_rows_cols_with_default(480, 640, CV_8UC3, Scalar::all(0.0)).unwrap();
let result = crate::palm::detect_mediapipe_palm(&mut net, &frame, 0.65).unwrap();
assert!(result.is_empty(), "FAR Failure: Palm detected in solid black image!");
}- 實體影片時序斷言測試:
讀取測試影片
test_assets/one-hand-flipping.mp4,驗證單手翻轉時系統不會重複生成 Ghost 追蹤器(assert!(max_trackers <= 1))。
執行測試命令:
cargo test -- --nocapturerunning 7 tests
test vision::tests::test_load_mediapipe_onnx_models ... ok
test vision::tests::test_far_blank_image ... ok
test vision::tests::test_far_noise_image ... ok
test vision::tests::test_far_geometric_shapes ... ok
test vision::tests::test_video_one_hand_flipping ... ok
test result: ok. 7 passed; 0 failed實務限制與學習體會 (Limitations & Learning Takeaways)
作為一個個人實驗與學習專案,客觀來說它仍有一些實務上的限制:
- 單眼 RGB Webcam 的物理極限:缺乏紅外線與 3D 深度感測器,在極暗光線、強烈背光或手掌大幅度遮擋時,追蹤穩定度仍無法與 Apple Vision Pro 或 Leap Motion 等專用硬體相提攜。
- 手臂肌肉疲勞 (Gorilla Arm Effect):長時間懸空懸空手勢操控會導致手臂疲勞,在日常辦公或精細的像素級點擊時,實體滑鼠依然是不可替代的生產力工具。
- 全 CPU 推論開銷:雖然在現代電腦上能順暢執行,但若能進一步串接 TensorRT 或 DirectML 硬體加速,電力與 CPU 佔用表現會更好。
但作為 Rust 52 Projects 挑戰的一部分,這個專案已經達成了很好的學習與驗證目的!它讓我完整體驗了如何在 Rust 中整合 OpenCV 5 FFI、處理複雜的神經網路張量輸出、用純 Rust 設計時序平滑演算法,以及透過 Enigo 進行系統級輸入注入。
學到的 Rust 關鍵技術
| 技術主題 | 應用與實現 |
|---|---|
std::sync::mpsc Channel |
跨執行緒無鎖傳輸輕量 GestureState enum,確保 60 FPS 解耦 |
| OpenCV 5 DNN 模組 | 使用 dnn::read_net_def 與 reshape_nd 進行 ONNX 權重解析與張量前處理 |
| Win32 FFI | 宣告原生 GetSystemMetrics 動態取得多螢幕解析度 |
| 時序遲滯狀態機 | 透過貪婪歐式配對與 $[-30, +30]$ 遲滯投票鎖定左右手狀態 |
| 旋轉無關幾何算子 | 採用 3D 關節點相對於手腕的歐氏距離比值進行 360 度伸展判斷 |
結語
gesture-control 探索了用 Rust 打造即時電腦視覺工具的可能性。儘管它不完美,但作為一個探索型 side project,它好玩、有挑戰性,且充滿了技術趣味。
歡迎前往 GitHub 專案庫查看程式碼並提出改進建議!
參考資源
- gesture-control 原始碼 Repo — 本專案完整 Rust 程式碼
- OpenCV Zoo — MediaPipe Models — MediaPipe ONNX 模型官方庫
- Enigo Crates.io — Rust 跨平台 OS 輸入模擬庫