架設一台網路監控攝影機(IP Camera)或即時影像串流伺服器,是許多人在學習物聯網(IoT)與電腦視覺時的經典入門專案。但在實務上,最常遇到的技術瓶頸莫過於:硬體影像解碼與電腦視覺(CV)影像處理解析度太高,一不小心就把 Web 伺服器的非同步事件迴圈(Event Loop)給卡死。
在這次的 Rust 52 Projects 挑戰中,我用 Rust + OpenCV 5 + Axum (v0.7) + Tokio 打造了一個能跨平台運行於 Windows (MSVC) 與 Linux / 樹莓派 4(Raspberry Pi 4 ARM64)的高效能 IP Camera 視訊串流伺服器:ip-camera。
這篇文章將全面拆解其架構設計,包含硬體擷取與 Web 異步解耦、Worker 執行緒 JPEG 壓縮、tokio::sync::watch 廣播通知、OpenCV 5 圖像處理管線,以及無鏡頭環境下的雷達模擬備援機制!
專案亮點與技術特色
- 獨立多執行緒擷取迴圈 (
spawn_blocking):硬體影像擷取、CV 圖形繪製與 JPEG 壓縮隔離在獨立 Worker 執行緒,保持 Axum/Tokio 非同步 Web 事件迴圈極速回應。
- OpenCV 5 即時視覺管線:
- EMA 平滑即時 FPS 計數器。
- 高精度微秒級時間戳記 Overlay。
- 互動式 HUD 掃描框:含四角綠色標記、動態雷射掃描紅線與特定 ROI 區域動態高斯模糊(Gaussian Blur)。
- Worker 執行緒內建 JPEG 壓縮:在背景 Worker 執行緒直接完成
cv::Mat 到 JPEG 的 imencode 壓縮,Web 串流任務只需廣播 Raw Bytes,大幅降低多用戶同時連線時的 CPU 開銷。
tokio::sync::watch 異步喚醒機制:Web 串流任務在沒有新影格時完全處於休眠狀態(Zero Overhead),唯有 Worker 產出新影格時才被非同步喚醒發送,防止 Busy Polling。
- 韌性離線備援 (Mock Radar Scope):當無實體 Camera 連線時(如 Headless VM、CI 環境),自動切換至動態旋轉雷達掃描儀模擬視訊源。
- 現代 Glassmorphism Web 儀表板:內建暗黑玻璃擬物化 Web 介面,支援全螢幕、快照截圖與播放/暫停控制。
系統串流架構:視訊處理與 Web 伺服器解耦
為避免耗時的 OpenCV 矩陣運算與 JPEG 壓縮阻塞 Axum 處理 HTTP 請求的 Tokio Worker Threads,整個系統在架構上進行了徹底的分工解耦:
%%{init: { 'themeVariables': { 'fontSize': '18px', 'subGraphTitleFontSize': '20px' } }}%%
flowchart TD
subgraph CaptureWorker ["1. Capture & CV Worker Thread (spawn_blocking)"]
direction TD
A["VideoCapture / Mock Radar Source"] --> B["CV imgproc Pipeline (FPS, Timestamp, Scan Laser)"]
B --> C["Worker JPEG imencode (85% Quality)"]
C --> D["Update Shared Memory & Trigger Watch Signal"]
end
subgraph StateBridge ["2. Thread-Safe State & Notification Bridge"]
direction TD
E["Shared Frame State: Arc(Mutex(Vec(u8)))"]
F["Notification Channel: tokio::sync::watch"]
end
subgraph AxumServer ["3. Axum 0.7 Async Web Server (Tokio Event Loop)"]
direction TD
G["Client GET /stream Request"] --> H["watch_rx.changed().await (Async Sleep / Wake)"]
H --> I["Read JPEG Bytes from Shared Mutex"]
I --> J["futures_util stream::unfold"]
J --> K["Yield multipart/x-mixed-replace MJPEG Chunk"]
end
D -->|"1. Broadcast Frame Update"| StateBridge
StateBridge -->|"2. Wake Client Stream Task"| H
核心技術一:非同步解耦與 tokio::task::spawn_blocking
在 src/main.rs 中,我們建立了一個共享的線程安全記憶體空間 Arc<Mutex<Vec<u8>>> 保存最新一幀的 JPEG 壓縮 Byte Array,以及一個 watch::channel 負責通知廣播。
接著,透過 tokio::task::spawn_blocking 將 OpenCV 的硬體讀取與圖像處理解耦出去:
// Shared thread-safe storage for the latest encoded frame.
let frame_state = Arc::new(Mutex::new(initial_jpeg.to_vec()));
let frame_state_clone = Arc::clone(&frame_state);
// Watch channel to notify web clients of new frames.
let (watch_tx, watch_rx) = watch::channel(0u64);
// 將硬體 VideoCapture 迴圈隔離在獨立的 blocking worker 執行緒
let camera_index = args.camera_index;
let filters = args.filter.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = camera::run_capture_loop(camera_index, filters, frame_state_clone, watch_tx) {
error!("Capture loop exited with error: {:?}", e);
}
});
💡 架構深挖:為什麼不直接用 Channel 傳送 JPG 影像?
許多人在設計視訊串流時,第一直覺是建立一個 mpsc::channel 或 broadcast::channel,將壓縮好的 Vec<u8> JPG 影像直接扔進 Channel 送給 Web 伺服器。但在高效能 IP Camera 的情境下,我們選擇了 Arc<Mutex<Vec<u8>>> (共享記憶體) + tokio::sync::watch (訊號廣播) 的組合,主要考量如下:
-
保證「永遠只看最新影格 (Latest-Frame-Only)」的即時性:
IP 監控攝影機的核心要求是極低延遲與即時性。使用者想看的是「此刻發生了什麼」,而不是 3 秒前積壓在佇列裡的歷史影格。
若使用傳統管道(Channel Queue),當某些 Web 客戶端網路較慢(例如用手機 3G 觀看)時,Channel 內部會積壓大量未消費的 JPG 影格,導致畫面延遲越來越大(Lag)且記憶體不斷暴增。
而 watch 頻道搭配共享記憶體具有天然的 最新狀態覆寫 (Single-Slot Overwrite) 語意:慢速客戶端被喚醒時,永遠只會讀取到當前最新的那張 JPG 影格,自動跳過中間沒來得及發送的影格,實現零積壓、零延遲!
-
避免多連線時的記憶體重複複製 (Zero Fan-Out Memory Inflation):
若透過 broadcast channel 發送 Vec<u8> 影像,每當有 $N$ 個客戶端連線時,系統就必須將圖像 Payload 複製 $N$ 份或處理複雜的共享記憶體佇列。
採用 Arc<Mutex<Vec<u8>>> 後,無論是 0 個還是 100 個客戶端連線,背景 Worker 執行緒永遠只執行一次 JPEG 壓縮與單一記憶體覆寫,將記憶體開銷牢牢鎖定在最小的單影格大小。
-
無人觀看時的極致零開銷 (Zero-Cost Idle):
當無人連線觀看視訊時,Worker 執行緒更新 Arc<Mutex> 並呼叫 watch_tx.send(frame_id) 僅會更新一個 $u64$ 的 Frame Counter 號碼牌,完全不會產生任何排隊訊息開銷或記憶體洩漏風險。
核心技術二:OpenCV 5 圖像處理管線與 EMA FPS 計算
在 src/camera.rs 的擷取迴圈中,我們不僅處理影像,還利用 Exponential Moving Average (EMA) 演算法計算平滑的即時 FPS,避免數據劇烈跳動:
$$\text{FPS}_{\text{smoothed}} = 0.95 \cdot \text{FPS}_{\text{smoothed}} + 0.05 \cdot \text{FPS}_{\text{instant}}$$
// 計算即時動態 FPS
frame_count += 1;
let now = Instant::now();
let delta = now.duration_since(last_fps_time).as_secs_f64();
last_fps_time = now;
if delta > 0.0 {
let instant_fps = 1.0 / delta;
// 使用 EMA 演算法平滑化 FPS 數值
fps = fps * 0.95 + instant_fps * 0.05;
}
HUD 掃描框與動態雷射光束
在 CV 濾波器管線中,我們能隨意疊加高斯模糊 (Blur)、Canny 邊緣檢測 (Canny)、灰階 (Grayscale)、色彩反轉 (Invert),或者啟動一個帶有紅光雷射掃描與綠色 HUD 角落標記的 Scanner 模式:
FilterConfig::Scanner { margin } => {
if let Ok(size) = current_frame.size() {
let m = *margin;
let box_w = std::cmp::max(1, size.width - m * 2);
let box_h = std::cmp::max(1, size.height - m * 2);
let roi_rect = Rect::new(m, m, box_w, box_h);
// 畫出 HUD 外框與綠色四角標記
let _ = imgproc::rectangle(&mut current_frame, roi_rect, Scalar::new(99.0, 102.0, 241.0, 0.0), 2, imgproc::LINE_AA, 0);
// 計算雷射紅線上下掃描的位置
let scan_period = 100;
let scan_pos = (frame_count % scan_period) as i32;
let scan_y = m + (scan_pos * box_h / scan_period as i32);
let _ = imgproc::line(
&mut current_frame,
Point::new(m + 2, scan_y),
Point::new(m + box_w - 2, scan_y),
Scalar::new(0.0, 0.0, 255.0, 0.0), // 雷射紅光
2,
imgproc::LINE_AA,
0,
);
}
}
在 Worker 執行緒預先完成 JPEG 壓縮
為了避免 10 個 Web 客戶端連線時,伺服器必須執行 10 次重複的 JPEG 壓縮運算,我們直接在 Capture 迴圈最後將 Mat 壓縮成 85% 品質的 JPEG Byte 陣列:
let mut jpeg_buf = Vector::<u8>::new();
let mut encode_params = Vector::<i32>::new();
encode_params.push(imgcodecs::IMWRITE_JPEG_QUALITY);
encode_params.push(85); // 85% 品質:兼顧畫質與網路頻寬
imgcodecs::imencode(".jpg", &frame, &mut jpeg_buf, &encode_params)?;
// 將 compressed bytes 寫入共享記憶體,並發送 watch 廣播
{
let mut lock = frame_state.lock().unwrap();
*lock = jpeg_buf.to_vec();
}
let _ = watch_tx.send(watch_tx.borrow().wrapping_add(1));
核心技術三:Axum 0.7 異步 MJPEG 串流與 stream::unfold
多媒體 MJPEG (Motion JPEG) 串流採用 HTTP 標準的 multipart/x-mixed-replace; boundary=frame 標頭。每個 Chunk 由 --frame 分界,隨後接上 Content-Type: image/jpeg 與 raw bytes。
在 src/handlers.rs 中,我們透過 futures_util::stream::unfold 搭配 watch_rx.changed().await 構造出極致高效的非同步串流 Response:
pub async fn stream_handler(State(state): State<AppState>) -> impl IntoResponse {
let rx = state.watch_rx.clone();
let frame_state = state.frame_state.clone();
// 利用 stream::unfold 打造非同步影格串流
let stream = stream::unfold((true, rx, frame_state), move |(is_first, mut rx, frame_state)| async move {
if !is_first {
// 沒有新影格時,Task 在此處非同步休眠,不消耗 CPU!
if rx.changed().await.is_err() {
return None; // Sender dropped
}
}
// 從共享記憶體讀取最新的 JPEG Bytes
let jpeg_bytes = {
let lock = frame_state.lock().unwrap();
lock.clone()
};
// 封裝 multipart/x-mixed-replace boundary 封包
let header = format!(
"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
jpeg_bytes.len()
);
let mut body_bytes = Vec::new();
body_bytes.extend_from_slice(header.as_bytes());
body_bytes.extend_from_slice(&jpeg_bytes);
body_bytes.extend_from_slice(b"\r\n");
Some((
Ok::<Bytes, std::io::Error>(Bytes::from(body_bytes)),
(false, rx, frame_state),
))
});
let body = Body::from_stream(stream);
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("multipart/x-mixed-replace; boundary=frame"),
);
(headers, body)
}
💡 深入剖析 stream::unfold:如何用數行程式碼生成無限非同步串流?
在函數式程式設計(Functional Programming)與 Rust 非同步生態集中,unfold 是 fold 的對偶(Dual)操作:fold 是將一個集合「坍縮/歸約」成單一數值,而 unfold 則是從一個初始狀態種子(Initial State Seed)開始,依序「展開」產生一個無限或有限的非同步 Stream。
stream::unfold 的運算模型如下:
$$\text{unfold}\Big(S_0, f: S_k \to \text{Future}\langle\text{Option}(Item, S_{k+1})\rangle\Big) \implies \text{Stream}\langle Item\rangle$$
在 stream_handler 中,我們傳入初始狀態 Tuple (is_first: true, rx, frame_state):
- 第 1 次迭代 (
is_first = true):
跳過 rx.changed().await 等待,直接從共享記憶體讀取當前最新的 JPEG Byte Array,組裝出首張 multipart 封包。回傳 Some((Ok(Bytes), (false, rx, frame_state)))。這使得瀏覽器一開啟網頁就能瞬間載入第一張畫面(Zero Delay First Frame)!
- 後續迭代 (
is_first = false):
執行 rx.changed().await。此時 Tokio Task 會進入完全休眠狀態(0 CPU 消耗)。當背景 Capture Worker 發送新影格訊號時,Task 被喚醒,讀取最新 JPG bytes,並再次產出 Some((Ok(Bytes), (false, rx, frame_state)))。
- 串流終止 (
Sender dropped):
若背景擷取執行緒關閉,rx.changed().await 回傳 Err(_),閉包回傳 None,stream::unfold 便會優雅地宣告 Stream 結束,通知 Axum 與 TCP Socket 關閉連線。
為什麼選擇 stream::unfold 而非手動實作 Stream Trait?
若要手動為自訂 Struct 實作 futures_util::Stream,我們必須編寫 poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Item>>,手動處理 Pin 指標、 unsafe 轉換、狀態機以及 Waker 的註冊與喚醒機制。
stream::unfold 將這一切繁瑣的底層 Async State Machine 封裝起來,讓我們能直接在 async move 區塊中使用標準的 .await 語法,極致優雅地將異步頻道通知轉譯為 Axum 相容的 axum::body::Body::from_stream!
當網頁瀏覽器打開 http://localhost:8080/stream 時,無需任何額外的 JavaScript 播放器,<img> 標籤就能原生、滑順地播放即時視訊串流!
韌性離線備援:模擬雷達儀 (Mock Radar Scope)
在 Headless VM 或 CI/CD 環境下測試時,往往沒有實體 Webcam。專案設計了自動備援機制:當 videoio::VideoCapture 打開失敗時,自動進入 is_simulated = true 模式,繪製動態雷達掃描儀:
// 繪製動態旋轉雷達綠光束與脈衝目標
let center = Point::new(size.width / 2, size.height / 2);
let angle = (frame_count as f64 * 0.05) % (2.0 * std::f64::consts::PI);
let radius = 120.0;
let radar_end = Point::new(
(center.x as f64 + radius * angle.cos()) as i32,
(center.y as f64 + radius * angle.sin()) as i32,
);
imgproc::line(&mut frame, center, radar_end, Scalar::new(0.0, 255.0, 0.0, 0.0), 1, imgproc::LINE_AA, 0)?;
imgproc::put_text(&mut frame, "⚠️ CAMERA OFFLINE - RUNNING IN SIMULATED MODE", Point::new(20, size.height - 20), ...)?;
這保證了伺服器在任何環境下都能正常啟動並提供穩定的串流服務。
跨平台編譯與運行指南
1. Windows (MSVC) 環境
專案內附 build.bat 腳本,自動設定 OPENCV_LINK_PATHS、OPENCV_INCLUDE_PATHS 與 runtime DLL PATH:
# 編譯並運行 Release 版本
.\build.bat run --release
2. Linux & 樹莓派 4 (Raspberry Pi 4 ARM64)
在 Debian / Raspberry Pi OS 上,只要安裝 clang 與 pkg-config 即可輕鬆編譯:
sudo apt update && sudo apt install -y build-essential clang libclang-dev pkg-config
cargo run --release
實務討論與技術體會 (Technical Trade-offs)
作為一個教學與實驗導向的專案,我們也必須坦實討論 MJPEG 串流的技術權衡:
- MJPEG vs RTSP/H.264/H.265:
- 優點:MJPEG 不需要複雜的跨平台 H.264 硬體編碼器(如 NVENC 或 QuickSync),相容性極佳,所有瀏覽器用普通
<img> 標籤就能播放。
- 缺點:因為每一影格都是完整的 JPEG 圖片,缺乏 Frame 之間的 Intra-frame 壓縮,頻寬佔用較大(640x360@30fps 約需 2~4 Mbps)。
- 記憶體拷貝優化空間:
目前 shared state 採用
Mutex<Vec<u8>> 搭配 lock().clone(),在極高並發(如數百個客戶端)時可進一步改用 bytes::Bytes 實現零拷貝(Zero-Copy)廣播。
但作為 Rust 52 Projects 挑戰,這個專案完美展示了如何優雅地組合 Tokio 非同步生態系、Axum 7 路由 與 OpenCV C++ 綁定,打造出兼具效能與韌性的視訊服務!
學到的 Rust 關鍵技術
| 技術主題 |
應用與實現 |
tokio::task::spawn_blocking |
將同步硬體 I/O 與 OpenCV 密集計算隔離出 Tokio Event Loop |
tokio::sync::watch |
實現單一生產者、多消費者的無鎖影格更新通知廣播 |
futures_util::stream::unfold |
將異步頻道通知轉譯為符合 HTTP Standard 的 Response Stream |
| Axum v0.7 Router |
處理狀態注入 (AppState) 與 multipart/x-mixed-replace 標頭 |
OpenCV 5 imgproc & imgcodecs |
繪製 HUD 雷射框、計算 EMA FPS 並在 Worker 執行緒完成 JPEG 壓縮 |
結語
ip-camera 專案展示了 Rust 在高效能視訊串流與電腦視覺領域的強大能力。從非同步解耦到跨平台(Windows / 樹莓派)支援,整個設計簡潔而堅固。
歡迎前往專案 Repo 查看程式碼並親自試跑!
參考資源
想像一下:完全不需要摸滑鼠與鍵盤,只要在 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(())
}
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 背景模式
專案提供 PowerShell 腳本自動下載 OpenCV Zoo 模型:
這會將 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 -- --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)
作為一個個人實驗與學習專案,客觀來說它仍有一些實務上的限制:
- 單眼 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 專案庫查看程式碼並提出改進建議!
參考資源
你有沒有想過把一張普通的平面相片,變成立體、伸手就能摸到的 3D 浮雕(Bas-Relief)?
在傳統雕刻中,製作浮雕需要極高的工藝技術;而在 3D 列印領域,大家常玩的 Lithophane(光影透光相片)雖然漂亮,但必須背後有光源才能看清細節。如果我們想要的是一個真正的 3D 幾何實體——有凸起的面貌、有起伏的山脈細節、可以直接拿在手上,甚至放到 OpenSCAD 裡面加上外框和掛鉤,該怎麼做?
這就是 depth-relief 這個專案的起點。身為 Rust 52 Projects 挑戰的一部分,我用 Rust + OpenCV 寫了一個命令列工具。它能將單張平面照片(透過 MiDaS 深度學習模型)或左右雙眼立體對(透過 StereoBM 塊匹配)轉換為 2D 深度圖(Depth Map),接著透過**高頻照片細節融合與後處理管線(High-Pass Detail Fusion & Post-Processing Pipeline)刻印出銳利的五官,最後自動三角化生成完全封閉、無拓撲瑕疵(Watertight/Manifold)**的 3D STL 網格檔案,可以直接匯入 PrusaSlicer 或 Bambu Studio 進行 3D 列印!
實戰成果展示 (The patrick-22 Showcase)
在深入技術細節之前,我們先來看看這套系統在真實照片上的轉換效果。以下是我使用自己的個人照 patrick-22.jpg 進行 3D 浮雕生成的完整過程:
1. 原始 2D 輸入照片 (patrick-22.jpg) |
2. 後處理管線生成的深度圖 (patrick-22-depth.png) |
|
|
經由 depth-relief 生成的 3D STL 模型 (patrick-22.stl) 與 3D 旋轉預覽效果:
3D 浮雕 STL 靜態渲染圖 (patrick-22-3d-render.png) |
3D 浮雕光影動態旋轉展示 (patrick-22-3d.gif) |
|
|
可以看到,即便原始深度模型(MiDaS)只給出了大致的大頭形狀,但透過我們設計的 High-Pass Detail Fusion,五官線條(眼睛、眉毛、鼻翼、嘴唇)、頭髮輪廓與五官立體感都被精準地刻印到了 3D 浮雕表面上!
系統架構與處理流程
把一張 2D 圖片變成可 3D 列印的 STL 檔案,中間跨越了電腦視覺、圖像訊號處理與 3D 幾何建模三個領域。整個 depth-relief 的處理管線如下:
graph TD
A["輸入照片 patrick-22.jpg"] --> B{"模式選擇"}
B -->|"單張照片"| C["MiDaS v2.1 ONNX 深度學習推理"]
B -->|"立體對 left/right"| D["StereoBM 視差匹配"]
C --> E["原始 2D 深度圖 Raw Depth"]
D --> E
E --> F["深度圖後處理管線 DepthPostProcessOptions"]
F --> F1["1. 分位數裁切 Quantile Clipping"]
F1 --> F2["2. Gamma 非線性對比擴充"]
F2 --> F3["3. 高頻照片細節融合 High-Pass Detail Fusion"]
F3 --> F4["4. 雙邊邊界保持平滑 Bilateral Filtering"]
F4 --> F5["5. 自適應直方圖等化 CLAHE"]
F5 --> G["匯出 8-bit Grayscale PNG 預覽圖"]
F5 --> H["3D 網格三角化 Triangulation"]
H --> I["計算頂點 Z 軸與法向量 Normal"]
I --> J["縫合 Top / Bottom / 4 側邊牆面"]
J --> K["匯出 Binary STL patrick-22.stl"]
K --> L["切片專案 patrick-22.3mf / OpenSCAD"]
整個架構主要分為三大核心模組:
depth.rs:負責 OpenCV 影像處理、ONNX 神經網路推理、雙眼立體匹配,以及全新設計的 DepthPostProcessOptions 深度後處理管線。
stl.rs:負責幾何運算、頂點生成、表面與側牆三角化,以及二進位 STL 格式寫入。
main.rs:CLI 命令列解析(Clap)、模型自動下載與各種後處理參數調控。
核心技術一:單圖深度估計 (MiDaS ONNX)
在沒有深度相機(如 LiDaR 或 RealSense)的情況下,要從單張普通照片推算像素的遠近,傳統演算法幾乎做不到。但 AI 深度學習模型可以做到。
我們選用 Intel ISL 開源的 MiDaS v2.1 Small 模型。這個模型非常輕量(約 58MB),既能在 CPU 上快速執行,又能預測出相當不錯的整體空間相對深度。
1. 自動模型下載機制
為了提升 DX(開發者體驗),使用者不需要手動尋找並下載 ONNX 模型。如果程式偵測到本地缺少 model-small.onnx,會自動啟動 subprocess 透過 curl(若失敗則切換至 PowerShell Invoke-WebRequest)從 Intel ISL 官方 GitHub Release 下載:
fn check_and_download_model(model_path: &Path) -> Result<()> {
if model_path.exists() {
return Ok(());
}
println!("MiDaS model file '{}' not found.", model_path.display());
println!("Downloading model-small.onnx (approx. 58MB)...");
let url = "https://github.com/intel-isl/MiDaS/releases/download/v2_1/model-small.onnx";
let status = std::process::Command::new("curl")
.arg("-L").arg("-o").arg(model_path).arg(url)
.status();
// 支援 Windows 預設環境的 PowerShell 備援機制
if status.is_err() || !status.unwrap().success() {
// Invoke-WebRequest 備援...
}
Ok(())
}
2. OpenCV 5 DNN 推理與 ImageNet 標準化
在 OpenCV 5 中,read_net_from_onnx 的 API 略有調整。MiDaS Small 模型要求輸入尺寸為 $256 \times 256$,且 RGB 像素值必須經過 ImageNet 的均值(Mean)與標準差(Std)歸一化:
$$R_{norm} = \frac{R - 0.485}{0.229}, \quad G_{norm} = \frac{G - 0.456}{0.224}, \quad B_{norm} = \frac{B - 0.406}{0.225}$$
在 Rust 中的處理邏輯:
pub fn estimate_depth_single(
img: &Mat,
model_path: &str,
options: &DepthPostProcessOptions,
) -> Result<Mat> {
let mut net = dnn::read_net_from_onnx_def(model_path)?;
// 1. Resize 至 256x256
let target_size = Size::new(256, 256);
let mut resized = Mat::default();
imgproc::resize(img, &mut resized, target_size, 0.0, 0.0, imgproc::INTER_CUBIC)?;
// 2. BGR 轉 RGB 並進行 ImageNet Normalization
let mut preprocessed = Mat::new_rows_cols_with_default(
target_size.height, target_size.width, opencv::core::CV_32FC3, Scalar::default()
)?;
for y in 0..target_size.height {
for x in 0..target_size.width {
let bgr: opencv::core::Vec3b = *resized.at_2d::<opencv::core::Vec3b>(y, x)?;
let r = (bgr[2] as f32) / 255.0;
let g = (bgr[1] as f32) / 255.0;
let b = (bgr[0] as f32) / 255.0;
let r_norm = (r - 0.485) / 0.229;
let g_norm = (g - 0.456) / 0.224;
let b_norm = (b - 0.406) / 0.225;
*preprocessed.at_2d_mut::<opencv::core::Vec3f>(y, x)? =
opencv::core::Vec3f::from([r_norm, g_norm, b_norm]);
}
}
// 3. 轉為 4D Blob 並執行 Forward 推理
let blob = dnn::blob_from_image(&preprocessed, 1.0, target_size, Scalar::default(), false, false, CV_32F)?;
net.set_input(&blob, "", 1.0, Scalar::default())?;
let mut output = Mat::default();
let out_blob_names = opencv::core::Vector::<String>::new();
net.forward(&mut output, &out_blob_names)?;
// 4. 提取原始 Raw Depth 矩陣
// ...
// 5. 進入深度圖後處理管線
post_process_depth_map(&raw_depth, Some(img), options)
}
核心技術二:高頻照片細節融合與 3D 人臉後處理管線 (High-Pass Detail Fusion)
在專案的第一個版本中,直接將 MiDaS 輸出的原始深度圖做全域線性歸一化並轉成 STL。然而實測後發現一個重大問題:MiDaS 這類深度學習模型擅長預測「巨觀空間輪廓」(例如鼻子比耳朵突出、人頭在背景前面),但完全無法預測「微觀高頻細節」(例如眼睛、雙眼皮、鼻翼線條、唇線、頭髮紋理與五官邊線)。
如果直接列印,出來的 3D 浮雕看起來會像一個平滑無表情的塑膠假人面具。
為了徹底解決這個問題,我設計了全新的五階段深度後處理管線 post_process_depth_map:
1. 分位數極值裁切 (Quantile Outlier Clipping)
背景極深處或前景極近處的離群值(Outliers)常會拉大深度值的全域範圍,導致主體人臉的 $Z$ 軸起伏壓縮在很窄的區間。透過統計分位數(如 $1\%$ 至 $99\%$):
$$\text{clip\_min} = \text{Quantile}(0.01), \quad \text{clip\_max} = \text{Quantile}(0.99)$$
強行將邊緣極值裁切掉後再歸一化至 $[0.0, 1.0]$,能讓 $100\%$ 的浮雕高度振幅($Z_{\text{relief}}$)完全貢獻給主體!
在 Rust 中的分位數排序與極值計算範例:
// 1. 收集有效深度像素值
let mut valid_vals: Vec<f32> = Vec::with_capacity(total_pixels);
for y in 0..h {
for x in 0..w {
let val = *raw_map.at_2d::<f32>(y, x)?;
if val.is_finite() && val > -0.5 {
valid_vals.push(val);
}
}
}
// 2. 分位數排序並計算 1% 與 99% 陣列索引
valid_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let num_valid = valid_vals.len();
let min_idx = ((num_valid - 1) as f32 * options.clip_min_quantile) as usize;
let max_idx = ((num_valid - 1) as f32 * options.clip_max_quantile) as usize;
let v_min = valid_vals[min_idx];
let v_max = valid_vals[max_idx];
let range = if (v_max - v_min).abs() > 1e-6 { v_max - v_min } else { 1.0 };
2. Gamma 非線性對比擴充 (Gamma Expansion)
人臉五官(如眼窩、鼻樑、面頰)的中間調變化非常微妙。透過套用 Gamma 曲線:
$$Z_{\text{gamma}} = (Z_{\text{norm}})^\gamma \quad (\text{預設 } \gamma = 0.7)$$
當 $\gamma < 1.0$ 時,曲線會在低中間調區間產生陡峭的斜率,非線性地拉開五官的中間調高度差,使原本平坦的面部立體感大幅躍升。
在 Rust 中的裁切 (Clamp)、歸一化與 Gamma 轉換範例:
let mut norm_map = Mat::new_rows_cols_with_default(h, w, CV_32F, Scalar::default())?;
for y in 0..h {
for x in 0..w {
let val = *raw_map.at_2d::<f32>(y, x)?;
if val <= -0.5 {
*norm_map.at_2d_mut::<f32>(y, x)? = 0.0;
} else {
let clamped = val.clamp(v_min, v_max);
let norm = (clamped - v_min) / range;
// 套用 powf 進行 Gamma 對比擴充
let final_val = norm.powf(options.gamma);
*norm_map.at_2d_mut::<f32>(y, x)? = final_val;
}
}
}
3. 高頻照片細節融合 (High-Pass Detail Fusion)
這是我覺得整個改善中最精采的亮點!我們從小照片的原始灰階影像中,透過高通濾波(High-Pass Filter)提取出邊緣與紋理資訊:
$$\text{HighPass}(x, y) = \text{Photo}_{\text{gray}}(x, y) - \text{GaussianBlur}(\text{Photo}_{\text{gray}}, \text{ksize}=15)$$
然後以加權參數 $\text{detail\_weight}$(預設 $0.4$)將這個高頻信號直接疊加回 3D 深度圖上:
$$Z_{\text{final}}(x, y) = \text{Clamp}\left(Z_{\text{gamma}}(x, y) + w \cdot \text{HighPass}(x, y), \, 0.0, \, 1.0\right)$$
這樣一來,原始照片中眼神的輪廓、嘴唇的紋理、頭髮的髮絲甚至衣領褶痕,都會被直接**刻印(Engrave)**到 3D 浮雕網格表面!
在 Rust 中的高通殘差計算與細節疊加範例:
// 高通細節提取函式
pub fn extract_high_pass_detail(img: &Mat, target_size: Size) -> Result<Mat> {
let mut gray = Mat::default();
imgproc::cvt_color(img, &mut gray, imgproc::COLOR_BGR2GRAY, 0, AlgorithmHint::ALGO_HINT_DEFAULT)?;
let mut resized_gray = Mat::default();
imgproc::resize(&gray, &mut resized_gray, target_size, 0.0, 0.0, imgproc::INTER_CUBIC)?;
let mut f32_gray = Mat::default();
resized_gray.convert_to(&mut f32_gray, CV_32F, 1.0 / 255.0, 0.0)?;
// 低頻模糊層
let mut blurred = Mat::default();
imgproc::gaussian_blur(&f32_gray, &mut blurred, Size::new(15, 15), 3.0, 3.0, opencv::core::BORDER_DEFAULT, AlgorithmHint::ALGO_HINT_DEFAULT)?;
// 計算高通殘差:原圖 - 低頻模糊 = 高頻細節
let mut detail_map = Mat::new_rows_cols_with_default(target_size.height, target_size.width, CV_32F, Scalar::default())?;
for y in 0..target_size.height {
for x in 0..target_size.width {
let hp = *f32_gray.at_2d::<f32>(y, x)? - *blurred.at_2d::<f32>(y, x)?;
*detail_map.at_2d_mut::<f32>(y, x)? = hp;
}
}
Ok(detail_map)
}
// 融合高頻細節至深度圖
if let Some(src_img) = opt_source_img {
if options.detail_weight > 0.0 {
let hp_detail = extract_high_pass_detail(src_img, Size::new(w, h))?;
let weight = options.detail_weight;
for y in 0..h {
for x in 0..w {
let base_val = *norm_map.at_2d::<f32>(y, x)?;
let hp_val = *hp_detail.at_2d::<f32>(y, x)?;
let blended = (base_val + weight * hp_val).clamp(0.0, 1.0);
*norm_map.at_2d_mut::<f32>(y, x)? = blended;
}
}
}
}
4. 雙邊濾波保邊平滑 (Bilateral Filtering)
最後,為了避免照片噪點導致 3D 浮雕表面過於粗糙,我們套用 OpenCV 的 bilateral_filter(雙邊濾波器)。雙邊濾波能在平滑微小噪點的同時,完全保留高頻融合進來的銳利邊緣:
let mut smooth_map = if options.smooth_radius > 0 {
let mut dst = Mat::default();
let d = options.smooth_radius * 2 + 1;
imgproc::bilateral_filter(
&norm_map,
&mut dst,
d,
0.1f64, // sigmaColor: 色彩/深度差異容許度
options.smooth_radius as f64, // sigmaSpace: 空間濾波半徑
opencv::core::BORDER_DEFAULT,
)?;
dst
} else {
norm_map
};
5. 自適應直方圖等化 (CLAHE Enhancement)
若開啟 --clahe 選項,程式會對深度圖進行 Contrast Limited Adaptive Histogram Equalization,提升局部區域的深度動態範圍:
if options.enable_clahe {
let mut u8_map = Mat::default();
smooth_map.convert_to(&mut u8_map, opencv::core::CV_8U, 255.0, 0.0)?;
let mut clahe_obj = imgproc::create_clahe_def()?;
clahe_obj.set_clip_limit(3.0)?;
clahe_obj.set_tiles_grid_size(Size::new(8, 8))?;
let mut clahe_out = Mat::default();
clahe_obj.apply(&u8_map, &mut clahe_out)?;
let mut f32_clahe = Mat::default();
clahe_out.convert_to(&mut f32_clahe, CV_32F, 1.0 / 255.0, 0.0)?;
smooth_map = f32_clahe;
}
核心技術三:雙眼立體對比 (Stereo Photo Mode)
除了 AI 估算,如果我們手上有校正過的左右雙眼照片(Stereo Pair),也可以使用經典的視差演算法 StereoBM(Block Matching)。
人眼看世界之所以有立體感,是因為左右眼位置不同造成的「視差」(Disparity)。物件離越近,視差越大;物件離越遠,視差越小。同樣地,StereoBM 計算完視差圖後,也會通過同一個 post_process_depth_map 後處理管線增強細節。
核心技術四:建構 Watertight 3D STL 實體網格
這整個專案最硬核、也最有成就感的部分,就是如何把這張 2D 的深度灰階圖轉成真正的 3D Watertight 實體模型。
為什麼 Watertight (封閉實體) 這麼重要?
3D 列印切片軟體(Slicer)把 3D 模型切成一層層 G-code 時,必須明確知道這個模型哪裡是「內部(Solid inside)」,哪裡是「外部(Outside)」。
如果只是把 2D 深度圖畫成一片薄薄的 3D 地形曲面(Heightfield Surface),它是一張沒有厚度的「紙」。Slicer 看到這張紙會無法計算體積,甚至直接報錯或印出空心碎片。
因此,我們必須像做豆腐塊一樣,幫這片頂層曲面加上封閉底座(Flat Base)與四周四面側牆(Side Walls)!
頂層高度曲面 (Top Surface Z = base + depth * relief)
/\__/\/\__
/ \
| | <--- 四周側牆 (Side Walls)
+------------+
平整底面 (Bottom Base Z = 0)
1. 頂點與座標軸變換
在影像座標系中,$Y$ 軸是向下延伸的(原點在左上角);而在 3D 列印座標系中,$Z$ 軸朝上,$Y$ 軸朝後。為了讓印出來的照片頂部朝向印床後方,我們需要翻轉 $Y$ 軸:
$$p_x = x \cdot s_x, \quad p_y = (H - 1 - y) \cdot s_y, \quad p_z = Z_{base} + \text{depth}(x, y) \cdot Z_{relief}$$
2. 三角化與法向量 (Winding Order & Normal Calculation)
STL 檔案由一個個三角面組成。每個三角面都必須遵守右手定則(Counter-Clockwise Winding Rule),確保法向量(Normal Vector)精準指向模型外側:
法向量透過向量外積計算:
$$\vec{N} = \frac{(V_2 - V_1) \times (V_3 - V_1)}{\|(V_2 - V_1) \times (V_3 - V_1)\|}$$
fn calculate_normal(v1: Vertex, v2: Vertex, v3: Vertex) -> Vertex {
let ax = v2.x - v1.x; let ay = v2.y - v1.y; let az = v2.z - v1.z;
let bx = v3.x - v1.x; let by = v3.y - v1.y; let bz = v3.z - v1.z;
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
let len = (nx * nx + ny * ny + nz * nz).sqrt();
if len > 1e-6 {
Vertex { x: nx / len, y: ny / len, z: nz / len }
} else {
Vertex { x: 0.0, y: 0.0, z: 0.0 }
}
}
對於網格中的每個方格,我們切成兩個三角形。特別注意的是:
- 頂面(Top Surface):頂點順序採用逆時針(CCW),法向量朝上。
- 底面(Bottom Base):頂點順序採用順時針(CW),法向量朝下(指向 $Z = 0$ 外部)。
- 側邊牆面(Side Walls):對左、右、前、後四個邊緣,將頂面的邊界頂點與底面的邊界頂點組合成 Rectangle Quad,再切成兩個三角形,法向量分別朝向 $-X, +X, -Y, +Y$ 外側。
這樣產生的 STL 檔案 100% 保證水密(Watertight),完全不需要在 Blender 或 MeshLab 裡面修理破面。
核心技術五:純 Rust 手寫 Binary STL 導出
雖然 Rust 生態系中有一些 3D 繪圖 crate,但 STL 檔案的二進位規格其實非常簡單精巧:
- Header (80 bytes):任意標頭文字。
- Number of Triangles (4 bytes u32, Little-Endian):三角形總數。
- Triangles Data (每個三角形 50 bytes):
- Normal Vector ($3 \times \text{f32} = 12 \text{ bytes}$)
- Vertex 1 ($3 \times \text{f32} = 12 \text{ bytes}$)
- Vertex 2 ($3 \times \text{f32} = 12 \text{ bytes}$)
- Vertex 3 ($3 \times \text{f32} = 12 \text{ bytes}$)
- Attribute Byte Count ($\text{u16} = 2 \text{ bytes}$)
完全不依赖大型 3D 引擎,只需利用 Rust 標準庫的 std::io::BufWriter 和 .to_le_bytes() 即可快速寫出高檔位的二進位 STL。
命令行實戰與 3MF 切片專案
你可以直接使用這行命令對自己的照片生成 STL 檔案:
cargo run --release -- --mode single --input patrick-22.jpg --output-stl patrick-22.stl --width 100 --detail-weight 0.4 --gamma 0.7
產生出 patrick-22.stl 之後,專案中也附帶了預先設定好的 patrick-22.3mf 3D 列印切片專案檔!
3D 列印切片設定建議
- 列印方向:匯入 PrusaSlicer 或 Bambu Studio 後,模型的平整底面會自動貼合在列印鋼板上($Z = 0$)。
- 層高(Layer Height):建議設定 0.12 mm 或 0.16 mm。極力推薦開啟 可變層高(Variable Layer Height)——讓平整的底座用 0.28 mm 快速印完,頂層細緻的浮雕起伏用 0.08 mm 精細堆疊。
- 填充(Infill):10% ~ 15% 的 Gyroid(陀螺儀) 填充即可提供極佳強度。
- 耗材選擇:啞光(Matte)單色 PLA 能呈現出最棒的光影輪廓;若使用半透明 PLA,還能兼具 Lithophane 光影透光效果!
OpenSCAD 整合範例
因為輸出的 STL 座標精確且原點位於左下角底面,你可以輕鬆將它匯入 OpenSCAD 進行 CSG 布林運算,例如幫浮雕加上相框與掛牆螺絲孔:
// OpenSCAD 浮雕加框與掛鉤腳本
difference() {
union() {
// 1. 外圍相框底座
cube([110, 110, 3], center = true);
// 2. 匯入 depth-relief 產生的 STL 並置中
translate([-50, -50, 1.5])
import("patrick-22.stl");
}
// 3. 挖出後方壁掛螺絲孔
translate([0, 50, 0])
cylinder(h = 20, r = 2.5, center = true, $fn = 32);
}
兩種模式對比總結
| 特性 |
單圖模式 (MiDaS ONNX) |
立體對模式 (StereoBM) |
| 輸入需求 |
單張普通照片/手機照片 (patrick-22.jpg) |
校正過的左右雙眼照片對 |
| 技術原理 |
深度學習神經網路推理 |
傳統視差塊匹配 (Block Matching) |
| 微觀細節 |
透過 High-Pass Photo Fusion 刻印高頻五官線條 |
依賴視差匹配精度與高頻融合 |
| 硬體需求 |
需載入 58MB ONNX 模型 |
純幾何運算,極輕量 |
| 適用場景 |
人像、風景、隨手拍生活照 |
雙鏡頭相機、立體繪圖、工業檢測 |
結語與學習心得
從最初只用 MiDaS 生成平滑但略顯呆板的 3D 地形,到引入 分位數裁切、Gamma 中間調擴充、高頻照片細節融合 的後處理管線,再到實際用 patrick-22.jpg 驗證生成 patrick-22.stl 和 patrick-22.3mf,depth-relief 實現了質的飛躍。
這個改善過程展現了工程設計中「結合多種技術」的力量:
- AI 深度學習(MiDaS) 負責給出宏觀、正確的整體空間 depth 骨架;
- 古典數位影像處理(High-Pass Filter & Gamma Curve) 負責補足微觀、銳利的細節紋理;
- 計算機幾何(Triangulation & Normals) 負責構建無瑕疵的 Watertight 實體。
三者結合,才讓一張平淡無奇的平面照片,真正蛻變成 3D 列印床上充滿細節、令人驚豔的實體浮雕作品!
參考資源
上一篇我們用 Rust + OpenCV 的 YuNet 模型做了靜態圖片和 Webcam 的即時人臉偵測。偵測到人臉之後,下一步自然會想到:能不能自動把人臉打上馬賽克?
這就是 face-mosaic 這個專案要做的事——讀入一段影片,自動偵測每一幀裡的人臉,打上模糊或像素化效果,然後輸出一支全新的影片。整個過程完全自動化,不需要手動框選任何東西。
從 face-detect 到 face-mosaic
如果你看過前一篇文章,會發現 face-detect 已經解決了最核心的問題:用 YuNet 找到人臉的 Bounding Box。face-mosaic 要做的就是在這個基礎上加兩件事:
- 影片處理管線:逐幀讀取 → 偵測 → 打碼 → 寫入輸出影片
- 模糊/像素化效果:在偵測到的人臉區域上套用視覺遮蔽
聽起來簡單,但實際動手才會發現影片處理有很多細節要處理。
影片處理的基本架構
影片處理的核心是一個 read-process-write 的迴圈。OpenCV 的 VideoCapture 負責讀取,VideoWriter 負責輸出:
let mut cap = videoio::VideoCapture::from_file(&input_path, videoio::CAP_ANY)?;
let fps = cap.get(videoio::CAP_PROP_FPS)?;
let width = cap.get(videoio::CAP_PROP_FRAME_WIDTH)? as i32;
let height = cap.get(videoio::CAP_PROP_FRAME_HEIGHT)? as i32;
let total_frames = cap.get(videoio::CAP_PROP_FRAME_COUNT)? as i64;
let mut writer = videoio::VideoWriter::new(
&output_path,
videoio::VideoWriter::fourcc('m', 'p', '4', 'v')?,
fps,
Size::new(width, height),
true, // isColor
)?;
這裡有幾個重要參數:
- FPS:從原始影片直接讀取,確保輸出影片的播放速度跟原始影片一致
- fourcc:影片編碼格式。
mp4v 是 MPEG-4 編碼,跟 .mp4 容器相容
- 尺寸:直接沿用原始影片的解析度
逐幀偵測與打碼
主要的處理迴圈長這樣:
let mut frame = Mat::default();
let mut frame_count: i64 = 0;
loop {
cap.read(&mut frame)?;
if frame.empty() {
break;
}
frame_count += 1;
// 更新偵測器的輸入尺寸
detector.set_input_size(frame.size()?)?;
// 偵測人臉
let mut faces = Mat::default();
detector.detect(&frame, &mut faces)?;
// 對每張偵測到的臉打碼
for i in 0..faces.rows() {
let x = *faces.at_2d::<f32>(i, 0)? as i32;
let y = *faces.at_2d::<f32>(i, 1)? as i32;
let w = *faces.at_2d::<f32>(i, 2)? as i32;
let h = *faces.at_2d::<f32>(i, 3)? as i32;
apply_mosaic(&mut frame, Rect::new(x, y, w, h))?;
}
writer.write(&frame)?;
if frame_count % 100 == 0 {
println!("Processed {}/{} frames", frame_count, total_frames);
}
}
每 100 幀印一次進度,因為處理長影片時沒有任何回饋會讓人以為程式當掉了。
馬賽克效果的兩種實現
臉部打碼可以用兩種方式實現:高斯模糊和像素化(馬賽克)。
高斯模糊
最直觀的做法是對人臉區域套用高斯模糊。OpenCV 的 gaussian_blur 一行搞定:
fn apply_blur(frame: &mut Mat, roi: Rect) -> Result<()> {
// 確保 ROI 不會超出圖片邊界
let roi = clamp_rect(roi, frame.size()?);
let mut face_region = Mat::roi(frame, roi)?;
let mut blurred = Mat::default();
// kernel size 越大越模糊,必須是奇數
imgproc::gaussian_blur(
&face_region,
&mut blurred,
Size::new(99, 99),
30.0, // sigmaX
30.0, // sigmaY
opencv::core::BORDER_DEFAULT,
)?;
blurred.copy_to(&mut face_region)?;
Ok(())
}
像素化(馬賽克)
像素化的原理也很巧妙:先把人臉區域縮小到很小(比如 10×10),再放大回原本的尺寸。因為放大時用的是最近鄰插值(Nearest Neighbor),就會產生經典的馬賽克方塊效果:
fn apply_mosaic(frame: &mut Mat, roi: Rect) -> Result<()> {
let roi = clamp_rect(roi, frame.size()?);
let face_region = Mat::roi(frame, roi)?;
let pixel_size = 10;
let small_size = Size::new(
(roi.width / pixel_size).max(1),
(roi.height / pixel_size).max(1),
);
// 縮小
let mut small = Mat::default();
imgproc::resize(
&face_region, &mut small,
small_size, 0.0, 0.0,
imgproc::INTER_LINEAR,
)?;
// 放大回原尺寸(最近鄰插值 = 馬賽克效果)
let mut mosaic = Mat::default();
imgproc::resize(
&small, &mut mosaic,
Size::new(roi.width, roi.height), 0.0, 0.0,
imgproc::INTER_NEAREST,
)?;
let mut face_mut = Mat::roi_mut(frame, roi)?;
mosaic.copy_to(&mut face_mut)?;
Ok(())
}
這種「先縮後放」的技巧比逐像素手動計算簡單很多,而且完全利用了 OpenCV 既有的 resize 函式。
ROI 邊界處理:一個容易被忽略的坑
YuNet 回傳的 Bounding Box 座標有時候會超出圖片邊界,特別是當人臉在畫面邊緣的時候。如果不處理,OpenCV 會直接 panic。所以需要一個 clamp 函式:
fn clamp_rect(rect: Rect, size: Size) -> Rect {
let x = rect.x.max(0);
let y = rect.y.max(0);
let w = rect.width.min(size.width - x);
let h = rect.height.min(size.height - y);
Rect::new(x, y, w.max(0), h.max(0))
}
這個函式確保 ROI 永遠在圖片範圍內。看起來很小,但沒有它,處理真實世界的影片時幾乎一定會爆掉。
效能考量
處理影片跟處理靜態圖片最大的差異在於效能。一段 30fps、10 分鐘的影片有 18,000 幀,每一幀都要跑一次 YuNet 偵測。幾個值得注意的點:
- 不需要對每幀做縮放:影片的每一幀解析度是固定的,不像靜態圖片可能有各種解析度。
set_input_size 只需要在第一幀或解析度改變時呼叫
- YuNet 夠快:這個模型是為邊緣裝置設計的,在一般筆電上一幀只需要幾毫秒
- 瓶頸在 I/O:影片的讀寫比偵測本身更花時間,特別是寫入大型 MP4 檔案時
在我的測試中,處理一段 1080p 影片大約能跑到 15-25 fps,主要受限於影片解碼和編碼的速度。
跟 face-detect 的程式碼共用
face-mosaic 和 face-detect 的核心偵測邏輯完全一樣——都是用 FaceDetectorYN 呼叫 YuNet 模型。差別在於:
|
face-detect |
face-mosaic |
| 輸入 |
靜態圖片 / Webcam |
影片檔案 |
| 輸出 |
畫框 + 特徵點 |
模糊/馬賽克 |
| 處理方式 |
單張 / 即時串流 |
逐幀批次處理 |
| 寫入 |
圖片 / GUI 視窗 |
VideoWriter → MP4 |
兩個專案都用了本地 patch 過的 opencv-rust(因為要支援 OpenCV 5),依賴結構也幾乎相同。
學到的東西
這個專案最有趣的不是演算法,而是影片處理的工程細節:
| 概念 |
應用 |
VideoCapture / VideoWriter |
OpenCV 的影片 I/O API |
| fourcc 編碼 |
指定影片壓縮格式 |
| ROI (Region of Interest) |
只對圖片的局部區域操作 |
| Nearest Neighbor 插值 |
放大時產生馬賽克效果 |
| 邊界 Clamp |
防止 ROI 超出圖片範圍 |
結語
face-mosaic 是 face-detect 的自然延伸。人臉偵測本身只是起點,真正有趣的是你拿偵測結果來做什麼——這裡是打碼,但同樣的架構也可以拿來做人臉追蹤、表情辨識、或者臉部特效。
影片處理讓整個專案的複雜度跳了一級,但 Rust 的型別系統在這裡依然很有幫助:Mat 的生命週期管理、Result 的錯誤傳播、以及編譯器對邊界條件的提醒,都讓你在處理 18,000 幀的迴圈裡更有信心。
參考資源
說到電腦視覺,大家第一個想到的語言通常是 Python。但如果你跟我一樣,覺得「都用 Rust 了,為什麼不連人臉偵測也用 Rust 來寫?」——那這篇文章就是寫給你的。
這次的 Rust 52 Projects 挑戰,我用 OpenCV 內建的 YuNet 輕量級深度學習模型,寫了一個能處理靜態圖片和即時 Webcam 影像的人臉偵測工具。除了畫 Bounding Box,還會標記出五個臉部特徵點(雙眼、鼻尖、左右嘴角),每個特徵點用不同的顏色呈現。
為什麼選 YuNet?
人臉偵測的模型百百種,從古早的 Haar Cascade 到各種 YOLO 變體都有。我最後選了 YuNet 有幾個原因:
- OpenCV 原生支援:從 OpenCV 4.7 開始,
FaceDetectorYN 就是內建 API,不用另外裝什麼深度學習框架
- ONNX 格式:模型就是一個
.onnx 檔案,下載下來直接用
- 夠快夠準:這個模型是專門為邊緣裝置設計的,在一般筆電上跑即時 Webcam 完全不是問題
- 特徵點偵測:不只給你一個方框,還附帶五個臉部特徵點的座標
環境建置:opencv-rust 的地雷區
在 Rust 裡使用 OpenCV,靠的是 opencv-rust 這個綁定庫。坦白說,這大概是整個專案裡最痛苦的部分。在 Windows 上需要先用 vcpkg 安裝 OpenCV:
vcpkg install opencv4:x64-windows
然後設定一堆環境變數:
$env:OPENCV_LINK_LIBS = "opencv_world4"
$env:OPENCV_LINK_PATHS = "C:\path\to\vcpkg\installed\x64-windows\lib"
$env:OPENCV_INCLUDE_PATHS = "C:\path\to\vcpkg\installed\x64-windows\include"
另外因為 opencv-rust 的 build script 會用 libclang 來解析 C++ header 產生 Rust 綁定,所以還需要確保系統裝了 LLVM/Clang。Cargo.toml 中啟用了 clang-runtime feature:
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
opencv = { path = "opencv-rust-patch", features = ["clang-runtime"] }
你可能注意到我用的是 path = "opencv-rust-patch" 而不是 crates.io 上的版本。這是因為我需要針對 OpenCV 5 做一些修正,所以 fork 了一份放在本地。
用 Clap 定義 CLI 介面
工具的使用方式很簡單:不帶參數就開 Webcam,指定 --image 就處理靜態圖片。用 clap 的 derive macro 定義起來非常清爽:
#[derive(Parser)]
#[command(name = "face-detect", version)]
struct Cli {
/// Path to an image file. If omitted, opens the webcam.
#[arg(short, long)]
image: Option<PathBuf>,
/// Path to the YuNet ONNX model file.
#[arg(short, long, default_value = "face_detection_yunet_2023mar.onnx")]
model: PathBuf,
/// Minimum confidence score to keep a detection (0.0–1.0).
#[arg(short, long, default_value_t = 0.9)]
score_threshold: f32,
/// NMS IoU threshold (0.0–1.0).
#[arg(short, long, default_value_t = 0.3)]
nms_threshold: f32,
/// Path to save the annotated output image (image mode only).
#[arg(short, long)]
output: Option<PathBuf>,
}
這裡有兩個可以調整的閾值:
- score_threshold:信賴度門檻,只有信心分數超過這個值的偵測結果才會保留。預設 0.9 表示「非常有把握才算」
- nms_threshold:Non-Maximum Suppression 的 IoU 門檻,用來消除重疊的偵測框
建立 YuNet 偵測器
建立偵測器的程式碼意外地簡潔。FaceDetectorYN::create 就是 OpenCV 對 YuNet 模型的封裝:
let mut detector = FaceDetectorYN::create(
model_path,
"", // config(YuNet 不需要)
Size::new(320, 320), // 初始 input_size,之後會根據實際圖片調整
cli.score_threshold,
cli.nms_threshold,
5000, // top_k:最多保留幾個候選框
0, // backend:預設
0, // target:CPU
)?;
值得注意的是 input_size 只是個初始值。實際上每次呼叫 detect 之前,我們都會用 set_input_size 把它調成當前圖片(或影格)的實際尺寸,讓模型知道要處理多大的輸入。
靜態圖片模式:縮放的藝術
處理靜態圖片有個實務上的考量:如果直接拿一張 4000×3000 的照片丟進去偵測,不僅速度慢,模型在太大的解析度下表現也不一定好。所以我做了一個等比例縮放的策略:
let size = img.size()?;
let max_dim = 800.0;
let mut scale = 1.0;
let mut detect_img = img.clone();
if size.width > 800 || size.height > 800 {
scale = f64::max(size.width as f64, size.height as f64) / max_dim;
let new_w = (size.width as f64 / scale).round() as i32;
let new_h = (size.height as f64 / scale).round() as i32;
let new_size = Size::new(new_w, new_h);
imgproc::resize(&img, &mut detect_img, new_size, 0.0, 0.0, imgproc::INTER_LINEAR)?;
}
關鍵在於:偵測完之後,我們需要把座標乘回去,這樣在原始解析度的圖片上畫框才會對齊:
if scale != 1.0 && count > 0 {
for i in 0..count {
for j in 0..14 {
let v = *faces.at_2d::<f32>(i, j)?;
*faces.at_2d_mut::<f32>(i, j)? = (v as f64 * scale) as f32;
}
}
}
注意只處理前 14 個欄位(座標),第 15 個欄位是信賴度分數,不需要縮放。
Webcam 即時模式
Webcam 模式就是一個經典的影像處理 loop:讀一張影格 → 偵測 → 畫框 → 顯示 → 檢查鍵盤輸入。
fn detect_in_webcam(detector: &mut impl FaceDetectorYNTrait) -> Result<()> {
let mut cam = videoio::VideoCapture::new(0, videoio::CAP_ANY)?;
if !cam.is_opened()? {
bail!("Cannot open default webcam (index 0)");
}
let window = "face-detect – webcam (press Q to quit)";
highgui::named_window(window, highgui::WINDOW_AUTOSIZE)?;
let mut frame = Mat::default();
loop {
cam.read(&mut frame)?;
if frame.empty() { continue; }
detector.set_input_size(frame.size()?)?;
let mut faces = Mat::default();
detector.detect(&frame, &mut faces)?;
draw_detections(&mut frame, &faces)?;
highgui::imshow(window, &frame)?;
let key = highgui::wait_key(1)?;
if key == b'q' as i32 || key == 27 { break; }
}
highgui::destroy_all_windows()?;
Ok(())
}
這裡有幾個 Rust 特色值得留意:
- Trait bound
impl FaceDetectorYNTrait:用 trait 而不是具體型別,讓函式更通用
bail! 巨集:來自 anyhow crate,等同於 return Err(anyhow!(...))
? 運算子:幾乎每一行 OpenCV 呼叫都可能失敗,? 讓錯誤處理保持簡潔
臉部特徵點的資料結構
YuNet 回傳的 faces 矩陣是一個 $N \times 15$ 的 Mat(f32),每一列代表一張偵測到的臉:
| 欄位 |
內容 |
| 0–3 |
Bounding Box 的 x, y, w, h |
| 4–5 |
右眼座標 🔵 |
| 6–7 |
左眼座標 🔴 |
| 8–9 |
鼻尖座標 🟢 |
| 10–11 |
右嘴角座標 🩷 |
| 12–13 |
左嘴角座標 🟡 |
| 14 |
信賴度分數 |
繪製的時候,我把五個特徵點設定了不同顏色(注意 OpenCV 用的是 BGR 色彩空間,不是 RGB):
const LANDMARK_COLORS: [(f64, f64, f64); 5] = [
(255.0, 0.0, 0.0), // 右眼 – 藍色
(0.0, 0.0, 255.0), // 左眼 – 紅色
(0.0, 255.0, 0.0), // 鼻尖 – 綠色
(255.0, 0.0, 255.0), // 右嘴角 – 粉色
(0.0, 255.0, 255.0), // 左嘴角 – 黃色
];
然後用一個迴圈把每個特徵點畫成小圓點:
for (j, &(b, g, r)) in LANDMARK_COLORS.iter().enumerate() {
let col = 4 + j as i32 * 2;
let lx = *faces.at_2d::<f32>(i, col)? as i32;
let ly = *faces.at_2d::<f32>(i, col + 1)? as i32;
imgproc::circle(
img,
Point::new(lx, ly),
3,
Scalar::new(b, g, r, 0.0),
imgproc::FILLED,
imgproc::LINE_AA,
0,
)?;
}
這段程式碼的小巧思在於用 enumerate 搭配固定的欄位偏移量 4 + j * 2,把五對 x/y 座標和五組顏色優雅地對應起來。
測試:不只是跑一跑而已
這個專案寫了蠻完整的測試。除了基本的功能測試,還有兩個用真實資料集跑的 benchmark:
基礎功能測試直接驗證偵測結果是否符合預期:
#[test]
fn test_face_detection_lena() -> Result<()> {
let mut detector = setup_detector(0.5)?;
let count = detect_in_image(&mut detector, &"tests/lena.jpg".into(), Some(&"tests/lena_out.jpg".into()))?;
assert_eq!(count, 1, "Should detect exactly 1 face in lena.jpg");
Ok(())
}
LFW 命中率 Benchmark 用 Labeled Faces in the Wild 資料集的 1000 張真實人臉照片,計算偵測器的命中率。要求至少 80%:
let hit_rate = (hits as f32 / images.len() as f32) * 100.0;
assert!(hit_rate >= 80.0, "Hit rate {:.1}% is below acceptable 80% threshold", hit_rate);
Stanford Background 誤判率 Benchmark 反過來,用完全不含人臉的風景照片(來自 Stanford Background Dataset),測試偵測器會不會「看到不該看到的臉」。要求誤判率低於 5%:
let fp_rate = (false_positives as f32 / total) * 100.0;
assert!(fp_rate <= 5.0, "False positive rate {:.1}% is above acceptable 5% threshold", fp_rate);
這種正反兩面夾擊的測試方式,讓你在調整 score_threshold 時有科學依據,而不是靠感覺。
學到的 Rust 概念
| 概念 |
應用場景 |
impl Trait 參數 |
讓偵測函式接受任何實作 FaceDetectorYNTrait 的型別 |
anyhow::Result |
統一錯誤處理,不用為每種錯誤定義型別 |
clap derive macro |
宣告式定義 CLI 介面 |
Mat 的泛型存取 |
at_2d::<f32>(i, j) 在型別安全下操作矩陣 |
| 模式匹配 + 解構 |
for (j, &(b, g, r)) 同時取得索引和解構元組 |
Option 驅動的分支 |
cli.image 的 Some/None 決定圖片模式或 Webcam 模式 |
結語
用 Rust 寫電腦視覺程式,最大的挑戰不在演算法本身,而是環境建置和 FFI 綁定。一旦搞定了 opencv-rust 的編譯問題,寫起來其實蠻順暢的——Rust 的型別系統和錯誤處理在這種「每一行都可能出錯」的 FFI 情境下,反而讓人特別安心。
如果你也想試試看,記得先下載 YuNet ONNX 模型,然後準備好耐心面對 OpenCV 的安裝過程 😄
參考資源