說到電腦視覺,大家第一個想到的語言通常是 Python。但如果你跟我一樣,覺得「都用 Rust 了,為什麼不連人臉偵測也用 Rust 來寫?」——那這篇文章就是寫給你的。

這次的 Rust 52 Projects 挑戰,我用 OpenCV 內建的 YuNet 輕量級深度學習模型,寫了一個能處理靜態圖片和即時 Webcam 影像的人臉偵測工具。除了畫 Bounding Box,還會標記出五個臉部特徵點(雙眼、鼻尖、左右嘴角),每個特徵點用不同的顏色呈現。

為什麼選 YuNet?

人臉偵測的模型百百種,從古早的 Haar Cascade 到各種 YOLO 變體都有。我最後選了 YuNet 有幾個原因:

  1. OpenCV 原生支援:從 OpenCV 4.7 開始,FaceDetectorYN 就是內建 API,不用另外裝什麼深度學習框架
  2. ONNX 格式:模型就是一個 .onnx 檔案,下載下來直接用
  3. 夠快夠準:這個模型是專門為邊緣裝置設計的,在一般筆電上跑即時 Webcam 完全不是問題
  4. 特徵點偵測:不只給你一個方框,還附帶五個臉部特徵點的座標

環境建置: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$ 的 Matf32),每一列代表一張偵測到的臉:

欄位 內容
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 命中率 BenchmarkLabeled 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.imageSome/None 決定圖片模式或 Webcam 模式

結語

用 Rust 寫電腦視覺程式,最大的挑戰不在演算法本身,而是環境建置和 FFI 綁定。一旦搞定了 opencv-rust 的編譯問題,寫起來其實蠻順暢的——Rust 的型別系統和錯誤處理在這種「每一行都可能出錯」的 FFI 情境下,反而讓人特別安心。

如果你也想試試看,記得先下載 YuNet ONNX 模型,然後準備好耐心面對 OpenCV 的安裝過程 😄

參考資源