深入剖析 Jetpack Compose 渲染架構:從 ComposeView 到 RenderNode 的動畫加速
身為 Android 開發者,你可能早已體驗過 Jetpack Compose 帶來的宣告式 UI 開發體驗。特別是使用 Modifier.graphicsLayer 製作位移、旋轉與透明度動畫時,畫面呈現出的 60 / 120 FPS 極致流暢度,常讓人讚嘆不已。
不過,你有沒有在寫程式時突然好奇過:
- Compose 作為全新設計的 UI 框架,究竟是如何無縫嵌入傳統 Android View 體系的?
- 當系統刷新畫面時,Compose 是如何「攔截」系統
Canvas 並畫出自己的元件?
- 為什麼透過
graphicsLayer 跑動畫時,即便主執行緒(Main UI Thread)被複雜邏輯暫時卡住,動畫依然能夠順暢播放?
這篇文章將帶大家一起打開 AndroidX Compose 原始碼(以 ComposeView、AndroidComposeView、CanvasHolder 與 RenderNode 為核心),一層層拆解 Compose 的底層渲染架構與動畫加速!
1. Compose 在 View System 中的宿主:ComposeView 與 AndroidComposeView
在傳統 XML 佈局或 View 體系中引進 Compose 時,我們的進入點通常是 ComposeView:
val composeView = ComposeView(context).apply {
setContent {
Text("Hello Compose!")
}
}
為什麼 ComposeView 繼承自 ViewGroup 而不是 View?
在 Android View 框架中,標準的 View 只能繪製自己,無法包含子 View;唯有 ViewGroup 才能透過 addView() 管理與擺放子視圖。
當我們呼叫 ComposeView.setContent 時,AbstractComposeView 會在內部建立一個關鍵的子 View——AndroidComposeView:
flowchart TD
AV["Android View 樹"] --> CV["ComposeView (ViewGroup)
• 公開 API 容器 & 生命週期管理"]
CV -->|唯一子 View| ACV["AndroidComposeView (ViewGroup)
• 內部橋樑 & 實現 Owner 介面"]
ACV -->|管理| Root["Root LayoutNode
• Compose 樹根節點"]
ACV -->|管理| AVH["AndroidViewsHandler
• 用於內嵌傳統 Android View"]
style AV fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a
style CV fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
style ACV fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12
style Root fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
style AVH fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
這裡的分工非常明確:
- 公開 API 與內部實現解耦:
ComposeView 負責處理生命週期策略 (ViewCompositionStrategy) 與 XML 佈局防護。它透過覆寫 checkAddView() 防止外部誤加原生 View:
// ComposeView.android.kt
private fun checkAddView() {
if (!creatingComposition) {
throw UnsupportedOperationException(
"Cannot add views to ${javaClass.simpleName}; only Compose content is supported"
)
}
}
當呼叫 createComposition() 時,AbstractComposeView 會經由 Wrapper.android.kt 實例化 AndroidComposeView 並將其掛載為唯一的子 View:
// Wrapper.android.kt
internal fun AbstractComposeView.setContent(
composeViewContext: ComposeViewContext,
content: @Composable () -> Unit
): Composition {
GlobalSnapshotManager.ensureStarted()
val composeView = if (childCount > 0) {
(getChildAt(0) as? AndroidComposeView)
} else {
removeAllViews()
null
} ?: AndroidComposeView(context, composeViewContext).also {
addView(it.view, DefaultLayoutParams)
}
val wrapped = composeView.getTag(R.id.wrapped_composition_tag) as? WrappedComposition
?: WrappedComposition(
composeView,
Composition(UiApplier(composeView.root), composeViewContext.compositionContext)
).also { composeView.setTag(R.id.wrapped_composition_tag, it) }
wrapped.setContent(content)
return wrapped
}
AndroidComposeView 也是 ViewGroup:因為它不僅要作為 Compose 樹的根節點(Owner),當我們在 Compose 中使用 AndroidView 嵌入傳統 View(如 WebView 或 MapView)時,AndroidComposeView 必須透過內部的 AndroidViewsHandler 容器來管理這些原生子 View。
- ViewTree 依賴與生命週期傳遞:
ComposeView 透過內部 ComposeViewContext 自動尋找並傳遞 View 樹中的 LifecycleOwner、SavedStateRegistryOwner 與 ViewModelStoreOwner,讓 Compose 能夠在 View 樹中正確注入 CompositionLocal 與狀態回復機制。
2. 繪圖攔截的魔法:為什麼是 dispatchDraw 而不是 onDraw?
當 Android 系統刷新畫面時,View 類別的 draw(Canvas) 函式會依序執行以下步驟:
// Standard android.view.View / ViewGroup draw pipeline:
public void draw(Canvas canvas) {
drawBackground(canvas); // 1. 繪製背景
onDraw(canvas); // 2. 繪製 View 本身內容
dispatchDraw(canvas); // 3. 繪製子 View 們 (ViewGroup 專屬)
onDrawForeground(canvas); // 4. 繪製滾動條與前景 Overlay
}
flowchart LR
A["View.draw(Canvas)"] --> B["1. drawBackground"]
B --> C["2. onDraw
(繪製 View 本身)"]
C --> D["3. dispatchDraw
(繪製子 View 們)"]
D --> E["4. onDrawForeground
(繪製前景 Overlay)"]
style A fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#0f172a
style B fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
style C fill:#fff5f5,stroke:#ef4444,stroke-width:2px,color:#991b1b
style D fill:#ecfdf5,stroke:#10b981,stroke-width:2.5px,color:#065f46
style E fill:#f8fafc,stroke:#64748b,stroke-width:1.5px,color:#334155
你可能會以為 Compose 是在 onDraw(Canvas) 中把元件印到畫面上,但如果你查閱 AndroidComposeView.android.kt 的原始碼,會發現一個有趣的現象:
override fun onDraw(canvas: android.graphics.Canvas) {
// 竟然是空的!
}
關鍵原因:Z-Order 排序與原生 View 混合繪製
Compose 選擇在 dispatchDraw(Canvas) 階段攔截 Canvas,原始碼中的關鍵實現如下:
// AndroidComposeView.android.kt
override fun dispatchDraw(canvas: android.graphics.Canvas) {
if (!isAttachedToWindow) {
invalidateLayers(root)
}
// 1. 確保繪圖前的測量與佈局已完成
measureAndLayout()
Snapshot.notifyObjectsInitialized()
isDrawingContent = true
try {
trace("AndroidOwner:draw") {
// 2. 將系統 Canvas 丟入 CanvasHolder 進行 Zero-Allocation 轉譯
canvasHolder.drawInto(canvas) {
// 3. 從 Compose 根節點遞迴繪製整棵 LayoutNode 樹
root.draw(canvas = this, graphicsLayer = null)
}
}
} finally {
isDrawingContent = false
}
}
其背後的主要考量為:
onDraw() 執行時間太早:onDraw() 在繪製任何子 View 之前 就執行完畢。如果 Compose 在 onDraw() 繪製 UI,那麼所有透過 AndroidView 嵌入的原生 View 永遠會蓋在 Compose UI 的上方,無法實現 Compose 元件與原生 View 的動態圖層交錯(Z-Index)。
- 在
dispatchDraw() 中精確控制圖層:dispatchDraw() 是 ViewGroup 繪製子 View 的時間點。Compose 在這裡接管 Canvas,就能精確控制 Compose 的 LayoutNode 樹與原生 View 之間的繪圖順序。
3. Zero-Allocation 繪圖適配器:CanvasHolder 與 AndroidCanvas
在 Android 系統中,dispatchDraw 傳進來的是 android.graphics.Canvas;而 Compose 內部使用的是跨平台的 androidx.compose.ui.graphics.Canvas 介面。
如果每一幀(每秒 60 ~ 120 次)繪圖時,Compose 都 new 一個包裝物件來轉換 Canvas,將會引發嚴重的記憶體抖動(Memory Churn)與 GC 卡頓。
為了避免這個問題,Compose 採用了 CanvasHolder 適配器設計:
sequenceDiagram
participant OS as Android OS
participant ACV as AndroidComposeView
participant CH as CanvasHolder
participant AC as AndroidCanvas Wrapper
participant Root as Compose LayoutNode Tree
OS->>ACV: dispatchDraw(systemCanvas)
ACV->>CH: drawInto(systemCanvas)
CH->>AC: 暫時將 internalCanvas 指向 systemCanvas
CH->>Root: root.draw(androidCanvas)
Root->>AC: 執行繪圖指令 (drawRect, drawText...)
AC->>OS: 直接委派給 systemCanvas 執行
CH->>AC: 繪製結束,恢復 internalCanvas
在 AndroidCanvas.android.kt 中:
public class CanvasHolder {
@PublishedApi internal val androidCanvas: AndroidCanvas = AndroidCanvas()
public inline fun drawInto(targetCanvas: android.graphics.Canvas, block: Canvas.() -> Unit) {
val previousCanvas = androidCanvas.internalCanvas
androidCanvas.internalCanvas = targetCanvas // 替換內部參考
androidCanvas.block() // 執行 Compose 繪圖
androidCanvas.internalCanvas = previousCanvas // 復原
}
}
透過這個簡單而精妙的設計,Compose 實現了 0 記憶體配置(Zero-Allocation) 的 Canvas 轉譯!
4. 繪圖圖層的硬體加速:RenderNodeLayer vs GraphicsLayerOwnerLayer
在 Compose 中,當我們為元件加上 Modifier.graphicsLayer 時,Compose 會為該節點建立一個獨立的繪圖圖層(OwnedLayer)。
原始碼中主要有兩種圖層實現:
RenderNodeLayer(早期實現):直接包裝 Android 系統的 RenderNodeApi29 / RenderNodeApi23,內部手動透過 OutlineResolver 來處理裁切與陰影。
GraphicsLayerOwnerLayer(現代 1.7+ 標準實現):Compose 將圖層抽象化為統一的 GraphicsLayer API。在 Android 10 (API 29+) 上,它底層會建立 GraphicsLayerV29,並直接使用 Android 系統公開的 android.graphics.RenderNode。
在 AndroidGraphicsContext.android.kt 原始碼中,我們可以看到系統如何根據 API 版本動態切換圖層實現:
// AndroidGraphicsContext.android.kt
override fun createGraphicsLayer(): GraphicsLayer {
synchronized(lock) {
val ownerId = getUniqueDrawingId(ownerView)
val layerImpl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: 使用官方 public RenderNode API
GraphicsLayerV29(ownerId)
} else if (isRenderNodeCompatible && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
try {
// Android 6.0~9.0: 使用隱藏的 framework RenderNode stubs
GraphicsLayerV23(ownerView, ownerId)
} catch (_: Throwable) {
isRenderNodeCompatible = false
GraphicsViewLayer(obtainViewLayerContainer(ownerView), ownerId)
}
} else {
// 舊版或相容性降級: 使用 ViewLayer 容器
GraphicsViewLayer(obtainViewLayerContainer(ownerView), ownerId)
}
return GraphicsLayer(layerImpl)
}
}
不論是哪一種實現,在 Android 10 (API 29) 以上的版本,兩者最終都會指向系統核心的 android.graphics.RenderNode!例如在 GraphicsLayerV29 中,屬性賦值會直接轉發給原生 RenderNode:
// GraphicsLayerV29.android.kt
override var translationX: Float = 0f
set(value) {
field = value
renderNode.translationX = value // 直接寫入 Native RenderNode
}
此外,GraphicsLayer 還支援彈性的 CompositingStrategy:
CompositingStrategy.Auto:系統自動判斷是否需要分配 GPU 離屏緩衝區(Offscreen Buffer)。
CompositingStrategy.Offscreen:強制建立 GPU 離屏緩衝區,用於複雜的 BlendMode 混色或遮罩渲染。
CompositingStrategy.ModulateAlpha:避開離屏緩衝區,直接將 Alpha 乘以子圖層繪製指令,大幅節省 GPU 記憶體頻寬。
5. 終極解密:為什麼 RenderNode 能讓動畫超流暢?
要理解 RenderNode 動畫流暢的秘密,我們必須了解 Android 的 雙執行緒繪圖架構:
- Main UI Thread(主執行緒 / Kotlin):執行 Compose 的 Composition、Layout 測量與
dispatchDraw。
- RenderThread(原生 C++ / HWUI / GPU 執行緒):負責處理 GPU 指令、發送 Vulkan / OpenGL ES 命令並渲染到螢幕。
flowchart TD
subgraph UIThread["1. Main UI Thread (Kotlin)"]
direction TB
M1["Composition & Layout 測量"] --> M2["RenderNode.beginRecording()"]
M2 --> M3["寫入 DisplayList 繪圖指令集 (C++)"]
M3 --> M4["RenderNode.endRecording()"]
end
subgraph SyncStep["2. VSYNC 幀同步"]
M4 -->|SyncFrameState 資料同步| R1
end
subgraph RenderStep["3. RenderThread (Native C++)"]
direction TB
R1["取得 DisplayList & RenderNode 4x4 矩陣屬性"] --> R2["Skia 引擎生成 GPU Shaders / Vulkan / OpenGL 指令"]
end
subgraph GPUStep["4. GPU 硬體加速繪製"]
R2 --> G1["輸出至螢幕 Surface Buffer 顯示 (60/120 FPS)"]
end
style UIThread fill:#f1f5f9,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
style SyncStep fill:#faf5ff,stroke:#a855f7,stroke-width:1.5px,color:#581c87
style RenderStep fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
style GPUStep fill:#fff7ed,stroke:#f97316,stroke-width:2px,color:#7c2d12
style M1 fill:#ffffff,stroke:#93c5fd,color:#0f172a
style M2 fill:#ffffff,stroke:#93c5fd,color:#0f172a
style M3 fill:#ffffff,stroke:#93c5fd,color:#0f172a
style M4 fill:#ffffff,stroke:#93c5fd,color:#0f172a
style R1 fill:#ffffff,stroke:#6ee7b7,color:#0f172a
style R2 fill:#ffffff,stroke:#6ee7b7,color:#0f172a
style G1 fill:#ffffff,stroke:#fdba74,color:#0f172a
許多人以為動畫改變 translationX 時,系統需要重新錄製 Display List,但事實並非如此!
一個 RenderNode 在記憶體中分為兩個獨立部分:
flowchart TD
RN["android.graphics.RenderNode (記憶體內部結構)"]
RN --> Header["1. RenderNode Header Properties
(可變 C++ 原生標頭屬性 — 變形與矩陣)
• translationX, translationY
• scaleX, scaleY
• rotationX, rotationY, rotationZ
• alpha, shadowElevation, pivotX, pivotY"]
RN --> DisplayList["2. Display List
(不可變 C++ 繪圖指令集串流 — 畫面內容)
• drawRect(0, 0, 100, 100)
• drawText('Hello World')
• drawBitmap(...)"]
style RN fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#ffffff
style Header fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
style DisplayList fill:#ecfdf5,stroke:#10b981,stroke-width:2px,color:#065f46
- Display List(繪圖指令集):儲存
drawRect、drawPath、drawText 等靜態繪畫命令。錄製後即不可變(Immutable)。
- RenderNode Header Properties(標頭屬性):包含
translationX, scaleX, rotationZ, alpha, shadowElevation 等 C++ float 欄位。
當你更新 graphicsLayer 的動畫數值時:
// 在 Compose 動畫中變更平移位置
Modifier.graphicsLayer {
translationX = animatedOffset // 每一幀都在改變
}
背後發生的事情是:
- ❌ 不需要 Re-composition:不會重新執行
@Composable 函數。
- ❌ 不需要 Re-layout:不會重新計算 LayoutNode 的尺寸與位置。
- ❌ 不需要重新錄製 Display List:完全不呼叫
beginRecording(),也不重新產生 C++ 繪圖指令。
- ✅ 僅更新 C++ 原生欄位:Compose 僅在
< 1 微秒 內直接修改 native RenderNode 物件上的 translationX float 數值!
在下一個 VSYNC 週期,RenderThread 在 GPU 上準備繪製時,它的 C++ 執行邏輯如下:
// RenderThread 內部的繪製虛擬碼
void drawRenderNode(RenderNode* node, Canvas* canvas) {
canvas->save();
// 1. 直接將 RenderNode Header 屬性套用為 4x4 變形矩陣 (GPU 矩陣運算)
canvas->translate(node->getTranslationX(), node->getTranslationY());
canvas->scale(node->getScaleX(), node->getScaleY());
canvas->rotate(node->getRotation());
canvas->setAlpha(node->getAlpha());
// 2. 播放完全沒變過的 Display List!
canvas->drawDisplayList(node->getDisplayList());
canvas->restore();
}
因為 Display List 完全不需要重寫,所有的位移與旋轉矩陣運算都直接丟給 GPU 處理,所以即便 Main UI Thread 剛好被複雜的業務邏輯卡住,RenderThread 依然能在 GPU 上以極致流暢的 60/120 FPS 播放動畫!
實務建議:Modifier.offset vs. Modifier.graphicsLayer
這也是為什麼在 Compose 效能最佳化實踐中,強烈推薦使用 graphicsLayer 跑動畫:
| 特性 |
Modifier.offset(x = 10.dp) |
Modifier.graphicsLayer { translationX = 10f } |
| 改變時觸發流程 |
Re-layout 佈局重新計算 + 重新錄製 Display List |
僅更新原生 C++ RenderNode 標頭矩陣屬性 |
| 主執行緒耗時 |
隨 UI 樹深度呈 $O(N)$ 增加 |
$< 1\,\mu\text{s}$ (微秒) |
| GPU 加速 |
每幀重新產生繪圖指令集 |
GPU 視訊記憶體硬件加速變形 |
結語
Jetpack Compose 不僅僅在語法層面帶來了宣告式 UI 的革新,在底層渲染架構上更展現了極致的效能考究:
- 透過
AbstractComposeView 與 AndroidComposeView 無縫銜接傳統 View System。
- 透過覆寫
dispatchDraw 實現與原生 View 的完美 Z-Order 混合繪製。
- 透過
CanvasHolder 達成零記憶體配置的繪圖轉譯。
- 透過
RenderNode 將動畫變形完全委派給 RenderThread 與 GPU,實現極致流暢的動畫體驗。
了解這些底層細節後,下次在寫 Compose UI 時,你就知道為什麼動畫推薦優先使用 Modifier.graphicsLayer 搭配 Lambda 傳值了!
(本文關於 Compose 的其他優秀架構設計,如 Slot Table、3-Phase Invalidation、Modifier.Node 等,將在後續文章中陸續為大家拆解!)
在 Rust 中用 OpenCV 5 與 Axum 7 打造跨平台高效能 IP Camera 視訊串流伺服器
架設一台網路監控攝影機(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 查看程式碼並親自試跑!
參考資源
在 Rust 中用 OpenCV 5 DNN 與 MediaPipe ONNX 打造零延遲 AI 手勢作業系統控制器
想像一下:完全不需要摸滑鼠與鍵盤,只要在 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 專案庫查看程式碼並提出改進建議!
參考資源
用 Rust + OpenCV 把 2D 相片變成 3D 列印浮雕 (Bas-Relief)
你有沒有想過把一張普通的平面相片,變成立體、伸手就能摸到的 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 實現影片自動人臉打碼
上一篇我們用 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 幀的迴圈裡更有信心。
參考資源