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);
        }
    }
}

valid_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let num_valid = valid_vals.len();

// 計算分位數對應的陣列索引 (例如 1% - 99%)
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. 裁切 (Clamp)、歸一化至 [0, 1] 並套用 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;
            let final_val = norm.powf(options.gamma);
            *norm_map.at_2d_mut::<f32>(y, x)? = final_val;
        }
    }
}

2. Gamma 非線性對比擴充 (Gamma Expansion)

人臉五官(如眼窩、鼻樑、面頰)的中間調變化非常微妙。透過套用 Gamma 曲線:

$$Z_{\text{gamma}} = (Z_{\text{norm}})^\gamma \quad (\text{預設 } \gamma = 0.7)$$

當 $\gamma < 1.0$ 時,曲線會在低中間調區間產生陡峭的斜率,非線性地拉開五官的中間調高度差,使原本平坦的面部立體感大幅躍升。

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 浮雕網格表面!

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)
}

4. 雙邊濾波保邊平滑 (Bilateral Filtering)

最後,為了避免照片噪點導致 3D 浮雕表面過於粗糙,我們套用 OpenCV 的 bilateral_filter(雙邊濾波器)。雙邊濾波能在平滑微小噪點的同時,完全保留高頻融合進來的銳利邊緣。


核心技術三:雙眼立體對比 (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 列印床上充滿細節、令人驚豔的實體浮雕作品!


參考資源