灰階寫完之後,我們就來寫下「亮度/對比」。這兩個剛好能驗證我們 Day 3 的契約在「有參數」的情境下會不會卡住:一樣走 Uint8Array
當像素、w*h*4
的長度規則,另外再加兩個 number
(在 Rust 端用 f64
)。我會把它做成一個函式 brightness_contrast(in_rgba, w, h, brightness, contrast) -> out_rgba
,Alpha 不動,只處理 RGB。前端只多兩個滑桿,按一下就能看到整張圖變亮、變暗、或提高對比。
開 wasm/src/lib.rs
,在灰階下面加一個新 API。參數我用最直覺的兩個數:brightness
以像素值為單位(-255 到 255),contrast
也以 -255 到 255 表示,轉換成經典的對比係數(Photoshop 同款公式)再套用:
factor = (259 * (contrast + 255)) / (255 * (259 - contrast))
,然後對每個通道做 new = factor*(old - 128) + 128 + brightness
,最後夾在 0~255。
/// 調整亮度與對比;brightness ∈ [-255,255];contrast ∈ [-255,255]
#[wasm_bindgen]
pub fn brightness_contrast(input: &[u8], w: u32, h: u32, brightness: f64, contrast: f64) -> Vec<u8> {
let expected = (w as usize) * (h as usize) * 4;
if input.len() != expected {
return input.to_vec();
}
// 對比轉換成係數;避免除以零
let c = contrast.max(-255.0).min(255.0);
let factor = (259.0 * (c + 255.0)) / (255.0 * (259.0 - c));
let b = brightness.max(-255.0).min(255.0);
let mut out = input.to_vec();
let mut i = 0usize;
while i < expected {
// R/G/B 調整;A 保留
for k in 0..3 {
let v = out[i + k] as f64;
let y = (factor * (v - 128.0) + 128.0 + b).round();
out[i + k] = y.clamp(0.0, 255.0) as u8;
}
// alpha 不動
i += 4;
}
out
}
存檔後在套件根重建一次:
rm -rf pkg
wasm-pack build --target web --out-dir pkg --out-name rustwasm_test
demo/src/main.ts
維持昨天的初始化,把 UI 多加兩個 input(亮度、對比)與一個按鈕。用 getImageData
拿到 Uint8ClampedArray
,用它的 buffer
建 Uint8Array
餵給 WASM;吃回新的位元組後 set()
回去、putImageData
畫回畫布。
// demo/src/main.ts
import init, { grayscale, brightness_contrast } from 'rustwasm-test'
import wasmUrl from 'rustwasm-test/rustwasm_test_bg.wasm?url'
await init({ module: wasmUrl })
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>
<div style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<div style="display:flex;gap:12px;align-items:center;">
<label style="width:56px">亮度</label>
<input id="b" type="range" min="-255" max="255" value="0" style="flex:1">
<span id="bv">0</span>
</div>
<div style="display:flex;gap:12px;align-items:center;">
<label style="width:56px">對比</label>
<input id="c" type="range" min="-255" max="255" value="0" style="flex:1">
<span id="cvv">0</span>
</div>
<div style="display:flex;gap:12px;">
<button id="apply">套用亮度/對比</button>
<button id="reset">還原</button>
</div>
</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 bEl = document.querySelector<HTMLInputElement>('#b')!
const cEl = document.querySelector<HTMLInputElement>('#c')!
const bv = document.querySelector<HTMLSpanElement>('#bv')!
const cvv = document.querySelector<HTMLSpanElement>('#cvv')!
const applyBtn = document.querySelector<HTMLButtonElement>('#apply')!
const resetBtn = document.querySelector<HTMLButtonElement>('#reset')!
const canvas = document.querySelector<HTMLCanvasElement>('#cv')!
const ctx = canvas.getContext('2d')!
let w = 0, h = 0
let original: ImageData | null = null
// slider
const syncLabel = () => { bv.textContent = bEl.value; cvv.textContent = cEl.value }
bEl.oninput = cEl.oninput = syncLabel
syncLabel()
// 載圖
pick.onchange = () => {
const file = pick.files?.[0]; if (!file) return
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
console.log('loaded', w, h)
w = img.naturalWidth; h = img.naturalHeight
canvas.width = w; canvas.height = h
ctx.drawImage(img, 0, 0)
original = ctx.getImageData(0, 0, w, h) // 備份原圖
bEl.value = '0'; cEl.value = '0'; syncLabel()
URL.revokeObjectURL(url)
}
img.src = url
}
// 轉灰階
go.onclick = () => {
if (!w || !h) return
const imgData = ctx.getImageData(0, 0, w, h)
const input = new Uint8Array(imgData.data.buffer)
const out = grayscale(input, w, h)
imgData.data.set(out)
ctx.putImageData(imgData, 0, 0)
original = ctx.getImageData(0, 0, w, h) // 將灰階結果當作新原圖
}
// 套用亮度/對比
applyBtn.onclick = () => {
if (!w || !h) return
const imgData = ctx.getImageData(0, 0, w, h)
const input = new Uint8Array(imgData.data.buffer)
const out = brightness_contrast(
input,
w, h,
Number(bEl.value), // brightness: -255~255
Number(cEl.value) // contrast: -255~255
)
imgData.data.set(out)
ctx.putImageData(imgData, 0, 0)
}
// 還原原圖
resetBtn.onclick = () => {
if (!ctx) { console.error('2D context 取得失敗'); return }
if (!w || !h || !original) return
ctx.putImageData(original, 0, 0)
bEl.value = '0'; cEl.value = '0'; syncLabel()
}
啟動:
cd demo
pnpm remove rustwasm-test
pnpm add file:../pkg
pnpm dev
好,為什麼把亮度/對比做成同一支函式呢?
因為這樣可以一次性把像素載入 L1 快取、在同一輪 loop 裡完成兩個調整,減少來回掃描;而把兩個參數都當 number(Rust 端 f64)正好貼合我們的契約,TS 也會補全。前端用滑桿把值限制在合理範圍(-255~255),就算使用者把參數拉到極端,Rust 端也會再 clamp 一次,保證輸出仍在合法的 0~255。
原本前端部分想把更改的地方一格格說明,但我發現來不及所以算了。就這樣吧 (´_ゝ`)