Simply Patrick

在 Rust 中用 OpenCV 5 DNN 與 MediaPipe ONNX 打造零延遲 AI 手勢作業系統控制器

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 專案庫查看程式碼並提出改進建議!


參考資源


用 Rust + OpenCV 把 2D 相片變成 3D 列印浮雕 (Bas-Relief)

featured.svg

你有沒有想過把一張普通的平面相片,變成立體、伸手就能摸到的 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)
patrick-22.jpg patrick-22-depth.png

經由 depth-relief 生成的 3D STL 模型 (patrick-22.stl) 與 3D 旋轉預覽效果:

3D 浮雕 STL 靜態渲染圖 (patrick-22-3d-render.png) 3D 浮雕光影動態旋轉展示 (patrick-22-3d.gif)
patrick-22-3d-render.png 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"]

整個架構主要分為三大核心模組:

  1. depth.rs:負責 OpenCV 影像處理、ONNX 神經網路推理、雙眼立體匹配,以及全新設計的 DepthPostProcessOptions 深度後處理管線。
  2. stl.rs:負責幾何運算、頂點生成、表面與側牆三角化,以及二進位 STL 格式寫入。
  3. 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 檔案的二進位規格其實非常簡單精巧:

  1. Header (80 bytes):任意標頭文字。
  2. Number of Triangles (4 bytes u32, Little-Endian):三角形總數。
  3. 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 列印切片設定建議

  1. 列印方向:匯入 PrusaSlicer 或 Bambu Studio 後,模型的平整底面會自動貼合在列印鋼板上($Z = 0$)。
  2. 層高(Layer Height):建議設定 0.12 mm0.16 mm。極力推薦開啟 可變層高(Variable Layer Height)——讓平整的底座用 0.28 mm 快速印完,頂層細緻的浮雕起伏用 0.08 mm 精細堆疊。
  3. 填充(Infill):10% ~ 15% 的 Gyroid(陀螺儀) 填充即可提供極佳強度。
  4. 耗材選擇:啞光(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.stlpatrick-22.3mfdepth-relief 實現了質的飛躍。

這個改善過程展現了工程設計中「結合多種技術」的力量:

  • AI 深度學習(MiDaS) 負責給出宏觀、正確的整體空間 depth 骨架;
  • 古典數位影像處理(High-Pass Filter & Gamma Curve) 負責補足微觀、銳利的細節紋理;
  • 計算機幾何(Triangulation & Normals) 負責構建無瑕疵的 Watertight 實體。

三者結合,才讓一張平淡無奇的平面照片,真正蛻變成 3D 列印床上充滿細節、令人驚豔的實體浮雕作品!


參考資源


用 Rust + OpenCV 實現影片自動人臉打碼

上一篇我們用 Rust + OpenCV 的 YuNet 模型做了靜態圖片和 Webcam 的即時人臉偵測。偵測到人臉之後,下一步自然會想到:能不能自動把人臉打上馬賽克?

這就是 face-mosaic 這個專案要做的事——讀入一段影片,自動偵測每一幀裡的人臉,打上模糊或像素化效果,然後輸出一支全新的影片。整個過程完全自動化,不需要手動框選任何東西。

從 face-detect 到 face-mosaic

如果你看過前一篇文章,會發現 face-detect 已經解決了最核心的問題:用 YuNet 找到人臉的 Bounding Box。face-mosaic 要做的就是在這個基礎上加兩件事:

  1. 影片處理管線:逐幀讀取 → 偵測 → 打碼 → 寫入輸出影片
  2. 模糊/像素化效果:在偵測到的人臉區域上套用視覺遮蔽

聽起來簡單,但實際動手才會發現影片處理有很多細節要處理。

影片處理的基本架構

影片處理的核心是一個 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 幀的迴圈裡更有信心。

參考資源


在 Rust 中用 OpenCV 實現即時人臉與特徵點偵測

說到電腦視覺,大家第一個想到的語言通常是 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 的安裝過程 😄

參考資源


tiny-llm-runner 深入解讀 (9):main.rs —— CLI、Prefill、Decode 與整體效能

featured.svg

本文由 AI Agent(Claude)代筆撰寫,文中的「我」指的是 AI Agent。Patrick 只有在文章最後做過潤飾調整。

歷經八篇深入解讀,我們終於來到 tiny-llm-runner 的最後一塊——main.rs。前面拆了那麼多零件,總得有人把它們兜起來吧?這個檔案就 130 行,是把所有元件串起來的「指揮台」。

這也是整個系列的最後一篇了。除了講 main.rs,我想在最後對整個專案做一個全局的效能最佳化清單,把每個檔案散落的優化點串起來看——算是給這趟旅程一個交代。

概念一:CLI 參數設計

#[derive(Parser, Debug)]
#[command(version, about = "Pure-Rust llama-architecture inference over a GGUF model")]
struct Args {
    #[arg(short, long)]                              model: PathBuf,
    #[arg(short, long, default_value = "Once upon a time")] prompt: String,
    #[arg(short, long, default_value_t = 64)]        n_predict: usize,
    #[arg(short, long, default_value_t = 0.8)]       temperature: f32,
    #[arg(long, default_value_t = 40)]               top_k: usize,
    #[arg(long, default_value_t = 42)]               seed: u64,
    #[arg(long)]                                     no_bos: bool,
    #[arg(long, default_value = "llama")]            rope: String,
}

clap 的 derive macro 把 CLI parsing 變成宣告式:每個欄位加上 #[arg(...)] 就自動產生 --model--prompt 之類的 flag。這比手寫 argument parser 短得多,而且 --help、type validation、default value 全都免費送你,實在是頗划算。

clap 的 zero-cost abstraction

clap 的 macro 在 compile-time 就生成好 parsing code,runtime 沒有任何 reflection。也就是說啟動時 Args::parse() 是純 native code,速度比 Python 的 argparse 快上好幾個量級。

對 LLM runner 來說 CLI 啟動時間其實不是什麼大問題,不過這個習慣很 Rust——把 metadata 處理推到 compile time,runtime 只留下純粹的計算。看多了你會發現整個語言都在貫徹這件事。

概念二:unsafe Mmap

let file = File::open(&args.model)?;
let mmap = unsafe { Mmap::map(&file)? };

整個專案唯一一個 unsafe,就這麼一行。為什麼 mmap 非得 unsafe 不可?

因為 mmap 違反了 Rust 的記憶體模型假設:Rust 假設一個 &[u8] 的內容在它的生命週期內不會被外部修改。但 mmap 對應的檔案如果被另一個 process 改掉(甚至 truncate),這個 &[u8] 就會看到變了樣的資料、最慘還會吃到 SIGBUS。

unsafe 說穿了就是程式設計師對編譯器的一句承諾:「我知道這違反一般規則,使用過程中檔案不會被外部動到,我自己負責」。對 LLM 模型檔來說這承諾其實很好守——模型檔通常就是 read-only 的,誰會去動它呢。

這也呼應了 Rust 的一個設計哲學:unsafe 不是禁忌,而是被精準框定的工具。整個 codebase 只有這一行 unsafe,但它被框得清清楚楚——出了問題,責任就在這一行,跑不掉。

演算法核心:Prefill / Decode 二段式

// 1. Prefill —— 處理 prompt
let prefill_start = Instant::now();
let mut last_logits: Option<Vec<f32>> = None;
for &tok in &prompt_ids {
    let logits = runner.forward(tok);
    last_logits = Some(logits.to_vec());
}
let prefill_elapsed = prefill_start.elapsed();

// 2. Decode —— 生成 token
let decode_start = Instant::now();
let mut generated: Vec<u32> = Vec::with_capacity(args.n_predict);
let mut logits = last_logits.expect("empty prompt");
for _ in 0..args.n_predict {
    let next = sampler.sample(&mut logits);
    if next == tokenizer.eos { break; }
    generated.push(next);
    let piece = tokenizer.decode(&[next]);
    print!("{piece}");
    std::io::stdout().flush().ok();
    logits = runner.forward(next).to_vec();
}

為什麼分兩階段?

LLM 推論天然分兩個階段:

  • Prefill:把使用者的 prompt 餵進去,建立 KV cache。logits 只有最後一個 token 的有用——前面的丟掉。
  • Decode:每次 forward 一個 token、抽下一個。每個 logits 都會用到。

這兩個階段的特性差異很有意思:

  • Prefill 的 token 全都是已知的,理論上可以批次處理(用 GEMM 取代 GEMV)。
  • Decode 就只能乖乖 sequential(下一個 token 取決於上一個,沒得偷懶)。

不過我目前 prefill 也是 sequential(一個 token 一個 forward)的,這就是個明擺著的優化機會了——後面清單會再回來算這筆帳。

演算法核心:tok/s 的計算

eprintln!("[prefill] {} tok in {:.2}s ({:.1} tok/s)",
    prompt_ids.len(),
    prefill_elapsed.as_secs_f64(),
    prompt_ids.len() as f64 / prefill_elapsed.as_secs_f64().max(1e-9),
);

max(1e-9) 是用來防止 0 秒(極短 prompt)導致除以零。f64::max(self, other) 回傳兩者較大者,所以 0.0.max(1e-9) = 1e-9,分母就保證不會是 0 了。

這種小細節很容易忘記寫喔——prompt 只有一個 token 時,prefill 可能是 0.001 秒,算出來還有意義;但要是快到變成 0 秒(測試環境有時就是這麼誇張),分母歸零你就會收到一個漂亮的 NaN。

Rust 用法:streaming output 的 flush

print!("{piece}");
std::io::stdout().flush().ok();

print! 寫進 stdout buffer,但不會馬上顯示——一般 stdout 是 line-buffered,要等到 \n 才 flush。LLM 串流輸出又沒有 \n,所以非得手動 flush 不可,不然你會傻等半天什麼都看不到,還以為當機了。

flush().ok()Result<(), Error> 轉成 Option<()> 然後丟掉——白話講就是「這個 flush 失不失敗我才懶得管」。stdout 寫入失敗本來就極罕見(例如 pipe 被人砍掉),就算真的失敗我們也無能為力,silent ignore 反而是最合理的處理。

Rust 用法:anyhow 的錯誤處理

fn main() -> Result<()> {
    // ... 整個 main 都是 Result-friendly 的,用 ? 早期返回
    Ok(())
}

fn main() -> Result<()> 是 Rust 處理 CLI errors 最乾淨的寫法。任何 ? 失敗都會把錯誤往 main 外面丟,runtime 自動 print 出來再 exit 1,連 error handling 的 boilerplate 都省了。

anyhow::Result<T> 不過就是 Result<T, anyhow::Error> 的別名。anyhow::Error 可以從任何 std::error::Error 自動轉換——這就是為什麼我能把 std::io::Error、parser error、自定義的 bail! 全混在一起,通通用一個 ? 打發掉,實在是頗舒服。

Rust 用法:環境變數和 stderr

eprintln!("[loaded] n_layer={} ...", config.n_layer, ...);

eprintln! 寫到 stderr,println! 寫到 stdout。我刻意把 metadata 印在 stderr、生成內容印在 stdout——這樣你用 ./tiny-llm-runner > out.txt 時,out.txt 裡就只有乾淨的生成內容,那些 metadata 還是乖乖留在 console 上,不會污染你的檔案。

這是 Unix 工具的老慣例了。在 Rust 裡用兩個不同的 macro 就自然支援,不必特別費心。

整個專案的端到端 forward pass 流程

flowchart TD A[CLI Args] --> B[File::open + Mmap] B --> C[parse_gguf] C --> D[LlamaConfig::from_gguf] C --> E[LlamaModel::load
建 TensorView] C --> F[Tokenizer::from_gguf] F --> G[encode prompt] D --> H[Runner::new
配 KV cache + scratch] E --> H G --> I[Prefill loop
forward each token] H --> I I --> J[Decode loop
sample → forward → repeat] J --> K[print tokens]

從 CLI 進來到 token 吐出去,整條流水線就這樣。仔細看會發現,圖裡每一個方框幾乎都對應到前面九篇文章其中一篇的主題——拼到這裡,整張地圖才算完整。

全局效能最佳化清單

到這裡,所有檔案都翻過一遍了。我想趁記憶猶新,把整個專案的最佳化機會匯總成一張 prioritized list。要強調的是:我不建議盲目地照著順序硬幹,還是得看你自己最想練哪一塊。

Tier 1(最大效能槓桿,10× 級的改進)

  1. SIMD 化 dot kernelsdequant.rs

    • Q4_0、Q8_0、Q6_K 的 inner loop 用 AVX2/AVX-512/NEON
    • 預期:matvec 加速 8-16×
    • 工作量:中—需要小心和 llama.cpp 對拍正確性
  2. Prefill batching(GEMV → GEMM)runner.rsops.rs

    • 把 prompt N 個 token 的 forward 拼成一個 batched 計算
    • 預期:prefill 加速 5-10×(decode 不變)
    • 工作量:大—涉及 attention 的 mask、KV cache 的 batched 寫入
  3. 支援 K-quants(Q4_K、Q5_K、Q4_K_M)dequant.rs

    • 不是加速 per se,而是讓現代 GGUF 都能跑
    • 工作量:中—實作複雜但有 ggml C 程式碼可參考

Tier 2(顯著改進,2-3× 級)

  1. Multi-row matvec fusionops.rs

    • 一次處理多個 row,減少 x 的 cache miss
    • 預期:matvec 加速 2-4×
  2. KV cache 量化runner.rs

    • 把 KV cache 從 F32 改成 Q8_0
    • 預期:記憶體用量 4×、速度可能略有提升(cache miss 變少)
    • 工作量:中
  3. f16 / bf16 全程(多個檔案)

    • 不要每次都 dequant 成 f32,scratch buffer 也用 f16
    • 預期:記憶體頻寬減半
    • 工作量:大—需要全程 f16 的 numerical stability 驗證

Tier 3(小但容易的改進)

  1. RoPE sin/cos 表預計算ops.rs

    • 不要每次 forward 都算 sin/cos
    • 預期:每層省幾十 μs,整體可能 1-2%
  2. Tokenizer 的 pair lookup tabletokenizer.rs

    • 避免 format! 字串拼接
    • 預期:encode 加速 5-10×(但 encode 不在 hot path)
  3. Top-P samplingsampler.rs

    • 提升 sampling 品質(不是速度,是輸出品質)
  4. Repetition penaltysampler.rs

    • 同上

Tier 4(架構級重構,可能不值得)

  1. GPU backend

    • wgpu 或 CUDA 支援
    • 工作量:極大—基本上是另一個專案
  2. FlashAttention

    • Fused attention with online softmax
    • 工作量:大—但 candle/ggml 有現成實作可學
  3. Speculative decoding

    • 用小模型加速大模型推論
    • 工作量:大—需要兩個模型協作

一個整體觀察:抽象與效能的權衡

寫完整個系列,我最強烈的感受是:tiny-llm-runner 的「易讀」,其實是拿「不可擴充」換來的。每個檔案都針對單一情境寫得直白到不行,但代價就是——要加新功能(新架構、新量化、新後端)往往得同時動好幾個檔案。

這跟 candle 的設計哲學根本是兩條路。candle 透過一層厚厚的抽象(TensorModuleVarBuilder)讓擴充變得很便宜,代價則是「想看懂一次 forward pass,得在好幾個 trait 之間跳來跳去」。

那到底哪個對?老實說,取決於你的目標。 你要做生產級框架,candle 那套抽象就是必要之惡;你要做一個「能跑、能讀、能 hack」的學習版,那 tiny-llm-runner 的扁平結構反而才是對的。沒有標準答案,只有適不適合。

把九個檔案的學習收穫匯總

回到我們最一開始問的那個問題:「寫一個會跑 LLM 的專案,到底需要哪些零件?」攤開來看,答案就是這張表:

檔案 你需要學會
config.rs metadata 解析 + 不變式檢查
dequant.rs block-wise 量化 + fused dot product
tensor.rs lifetime + view 抽象 + Copy struct
model.rs 樹形權重組織 + tied embeddings
ops.rs RMSNorm、softmax、RoPE、SwiGLU、rayon
runner.rs KV cache + GQA + 殘差連接 + scratch buffer
tokenizer.rs SentencePiece-BPE + byte fallback + UTF-8 重組
sampler.rs top-k partial sort + xorshift + CDF sampling
main.rs CLI design + prefill/decode split + tok/s metric

如果你真的把整個系列啃完了,那「LLM 推論引擎到底在幹嘛」這件事,你心裡應該已經有一張完整的 mental model 了。而且接下來能玩的還多著呢:自己加 SIMD、自己刻 GEMM、自己補 K-quants、自己接 GPU backend……每一條我都覺得夠格獨立寫成一篇工程旅程。

結語

回頭看,tiny-llm-runner 對我來說從來就不只是「一個專案」,而是「一次把 LLM 推論從頭到尾看透的旅程」。從一個 mmap 出來的 byte slice 開始,經過一連串型別、抽象、運算的層層組合,最後居然真的長成一個能跑、能跟 llama.cpp 對拍、又讀得懂的 Rust 程式——對我這種改不掉、就是愛把黑盒子拆開看裡面齒輪的工程師來說,這份滿足感,比把效能再快一倍都還過癮。 :-)

九篇走下來,最大的收穫其實不是哪個 kernel 怎麼寫,而是那種「啊,原來這裡面沒有魔法」的踏實感。LLM 聽起來玄,拆開來不過就是量化、矩陣、softmax、sampling 這些老朋友排排站而已。

謝謝你一路陪我把這九篇啃完。下次再見的時候,但願我已經把上面那張清單裡的優化,至少落地了幾個——不然這篇結語可就有點心虛了。我們,下個專案見。

系列文章: