iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Rust

把前端加速到天花板:Rust+WASM 即插即用外掛系列 第 5

Day 4|第一個影像 API:把一張圖「變灰」

  • 分享至 

  • xImage
  •  

好,昨天我們把 JS ↔ Rust 的交流通道打通,知道初始化、匯入、以及 Uint8Array 如何穿越邊界。今天正式動手做第一個功能:把一張圖變灰。為什麼從這裡開始?因為灰階會逼你把每個像素都讀一遍、理解 RGBA 的排列(四個連續位元組一組),做計算再寫回去;它是一個又小又完整的「影像處理最小閉環」。理解了之後的模糊、銳化、邊緣偵測,都只是把不同算式塞進同一個殼。

1. 先改 Rust

打開 wasm/src/lib.rs,加一個 grayscale 函式。接口沿用前兩天定下的契約:輸入是 Uint8Array 對應的 &[u8],長度必須等於 w*h*4;輸出回一段新的 Vec<u8>,長度也一樣。我用的是 BT.601 的近似權重,把 R/G/B 轉成亮度 Y,再把 Y 複製回三個通道,Alpha 保留原值。

mod utils;

use wasm_bindgen::prelude::*;

/// 灰階:輸入/輸出長度都必須是 w*h*4(RGBA)
#[wasm_bindgen]
pub fn grayscale(input: &[u8], w: u32, h: u32) -> Vec<u8> {
    let expected = (w as usize) * (h as usize) * 4;
    if input.len() != expected {
        return input.to_vec();
    }
    let mut out = Vec::with_capacity(expected);
    let mut i = 0usize;

    // 近似 BT.601:Y ≈ 0.299R + 0.587G + 0.114B
    // 用整數:Y ≈ (77R + 150G + 29B) >> 8
    while i < expected {
        let r = input[i] as u16;
        let g = input[i + 1] as u16;
        let b = input[i + 2] as u16;
        let a = input[i + 3];
        let y = ((77 * r + 150 * g + 29 * b) >> 8) as u8;
        out.extend_from_slice(&[y, y, y, a]);
        i += 4;
    }
    out
}

為什麼要「回傳新陣列」而不是原地改?因為這樣簡單,呼叫端拿到輸出就能直接覆蓋回畫布,不用擔心原始輸入同時被改壞。等到之後真的需要壓榨記憶體,之後再說:)

儲存好檔案後,在套件根目錄重新 build 一次,讓 .wasm/.js/.d.ts 更新:

rm -rf pkg
wasm-pack build --target web --out-dir pkg --out-name rustwasm_test

2. 前端

打開 src/main.ts ,UI 部分我直接插入一段表單和一個 <canvas>。點下「轉灰階」的時候,ctx.getImageData(0, 0, w, h) 會給一份 ImageData,裡頭的 dataUint8ClampedArray,也就是一段「長度 = wh4」的位元組,排列順序就是 RGBA。為了照我們 Day 3 定的契約把它交給 WASM,所以我用 new Uint8Array(imgData.data.buffer) 建一個共用同一塊記憶體的新檢視;WASM 端只吃 Uint8Array,但兩者底下都是同一個 ArrayBuffer,所以這個轉換幾乎是零成本。把這段 bytes 丟進 grayscale(input, w, h),回來會得到一個新的 Uint8Array。最後 imgData.data.set(out) 把結果覆蓋回 ImageData,再用 ctx.putImageData(imgData, 0, 0) 貼回畫布,畫面就會變灰。

import init, { grayscale } from 'rustwasm-test'
import wasmUrl from 'rustwasm-test/rustwasm_test_bg.wasm?url'

await init({ module: wasmUrl })

// UI
const app = document.querySelector<HTMLDivElement>('#app')!
app.innerHTML = `
  <div style="margin-top:24px;display:flex;gap:12px;align-items:center;">
    <input id="pick" type="file" accept="image/*">
    <button id="go">轉灰階</button>
  </div>
  <canvas id="cv" style="display:block;margin-top:12px;max-width:100%;border-radius:8px;"></canvas>
`

const pick = document.querySelector<HTMLInputElement>('#pick')!
const go = document.querySelector<HTMLButtonElement>('#go')!
const cv = document.querySelector<HTMLCanvasElement>('#cv')!
const ctx = cv.getContext('2d')!

let w = 0, h = 0

pick.onchange = async () => {
  const file = pick.files?.[0]
  if (!file) return
  const url = URL.createObjectURL(file)
  const img = new Image()
  img.onload = () => {
    w = img.naturalWidth
    h = img.naturalHeight
    cv.width = w
    cv.height = h
    ctx.drawImage(img, 0, 0)
    URL.revokeObjectURL(url)
  }
  img.src = url
}

go.onclick = () => {
  if (!w || !h) return
  const imgData = ctx.getImageData(0, 0, w, h)
  // Uint8ClampedArray 與 Uint8Array 共用同一個 ArrayBuffer
  const input = new Uint8Array(imgData.data.buffer)
  const out = grayscale(input, w, h)
  imgData.data.set(out)
  ctx.putImageData(imgData, 0, 0)
}

啟動:

cd demo
pnpm dev

跑起來後,你會選一張圖、按下「轉灰階」,圖片就會變灰了。(感謝炎龍老師的圖,我也不知道為什麼我電腦會有這張)

https://ithelp.ithome.com.tw/upload/images/20250919/20162491AJHlsMDcPR.png


今天聽了一個演講在講硬碟容量與刷新的算法,沒聽懂哈哈
每次上課下課都沒有 ubike 騎,真的很考驗我的脾氣,學習修身養性 (^_っ^)


上一篇
Day 3|API 契約 v1:string / number / Uint8Array
系列文
把前端加速到天花板:Rust+WASM 即插即用外掛5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言