好,昨天我們把 JS ↔ Rust 的交流通道打通,知道初始化、匯入、以及 Uint8Array
如何穿越邊界。今天正式動手做第一個功能:把一張圖變灰。為什麼從這裡開始?因為灰階會逼你把每個像素都讀一遍、理解 RGBA 的排列(四個連續位元組一組),做計算再寫回去;它是一個又小又完整的「影像處理最小閉環」。理解了之後的模糊、銳化、邊緣偵測,都只是把不同算式塞進同一個殼。
打開 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
打開 src/main.ts
,UI 部分我直接插入一段表單和一個 <canvas>
。點下「轉灰階」的時候,ctx.getImageData(0, 0, w, h)
會給一份 ImageData
,裡頭的 data
是 Uint8ClampedArray
,也就是一段「長度 = 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
跑起來後,你會選一張圖、按下「轉灰階」,圖片就會變灰了。(感謝炎龍老師的圖,我也不知道為什麼我電腦會有這張)
今天聽了一個演講在講硬碟容量與刷新的算法,沒聽懂哈哈
每次上課下課都沒有 ubike 騎,真的很考驗我的脾氣,學習修身養性 (^_っ^)