說到電腦視覺,大家第一個想到的語言通常是 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!巨集:來自anyhowcrate,等同於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 的安裝過程 😄
參考資源
- face-detect 原始碼 — 本文範例的完整程式碼
- OpenCV Zoo — YuNet 模型
- opencv-rust — Rust 的 OpenCV 綁定庫
- Labeled Faces in the Wild — LFW 人臉資料集