featured.svg

想像一下:完全不需要摸滑鼠與鍵盤,只要在 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) 通訊。

graph TD A[Webcam 視訊擷取 640x480] -->|BGR 影格| B[OpenCV 5 DNN 視覺執行緒] B -->|Palm Detection ONNX| C[192x192 掌心候選框 + 距離中心 NMS] C -->|Hand Crop + WarpAffine 旋轉對齊| D[Handpose Estimation ONNX] D -->|Layer 2 右手性雙向推論| E[Sub-millisecond 翻轉推論與反向映射] E -->|回傳 21 個 3D 骨骼點| F[360 度旋轉幾何算子] F -->|貪婪距離配對 + 遲滯投票| G[TrackedHand 時序追蹤器] G -->|眾數滑動視窗 N=10| H[Mode Debouncing 低通濾波器] H -->|GestureState Enum| I(std::sync::mpsc Channel) I -->|GestureState| J[主執行緒 OS Controller Loop] J -->|EMA 游標平滑化 + Win32 FFI| K[Native OS 滑鼠/鍵盤/虛擬桌面切換]

在入口點 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.onnxhandpose_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 -- --nocapture
running 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)

作為一個個人實驗與學習專案,客觀來說它仍有一些實務上的限制:

  1. 單眼 RGB Webcam 的物理極限:缺乏紅外線與 3D 深度感測器,在極暗光線、強烈背光或手掌大幅度遮擋時,追蹤穩定度仍無法與 Apple Vision Pro 或 Leap Motion 等專用硬體相提攜。
  2. 手臂肌肉疲勞 (Gorilla Arm Effect):長時間懸空懸空手勢操控會導致手臂疲勞,在日常辦公或精細的像素級點擊時,實體滑鼠依然是不可替代的生產力工具。
  3. 全 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_defreshape_nd 進行 ONNX 權重解析與張量前處理
Win32 FFI 宣告原生 GetSystemMetrics 動態取得多螢幕解析度
時序遲滯狀態機 透過貪婪歐式配對與 $[-30, +30]$ 遲滯投票鎖定左右手狀態
旋轉無關幾何算子 採用 3D 關節點相對於手腕的歐氏距離比值進行 360 度伸展判斷

結語

gesture-control 探索了用 Rust 打造即時電腦視覺工具的可能性。儘管它不完美,但作為一個探索型 side project,它好玩、有挑戰性,且充滿了技術趣味。

歡迎前往 GitHub 專案庫查看程式碼並提出改進建議!


參考資源