iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Rust

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

Day 5|亮度與對比:把參數塞進去

  • 分享至 

  • xImage
  •  

灰階寫完之後,我們就來寫下「亮度/對比」。這兩個剛好能驗證我們 Day 3 的契約在「有參數」的情境下會不會卡住:一樣走 Uint8Array 當像素、w*h*4 的長度規則,另外再加兩個 number(在 Rust 端用 f64)。我會把它做成一個函式 brightness_contrast(in_rgba, w, h, brightness, contrast) -> out_rgba,Alpha 不動,只處理 RGB。前端只多兩個滑桿,按一下就能看到整張圖變亮、變暗、或提高對比。

1. Rust 部分

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

2. 前端部分

demo/src/main.ts 維持昨天的初始化,把 UI 多加兩個 input(亮度、對比)與一個按鈕。用 getImageData 拿到 Uint8ClampedArray,用它的 bufferUint8Array 餵給 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

https://ithelp.ithome.com.tw/upload/images/20250920/201624911RjwDxamX9.png

好,為什麼把亮度/對比做成同一支函式呢?

因為這樣可以一次性把像素載入 L1 快取、在同一輪 loop 裡完成兩個調整,減少來回掃描;而把兩個參數都當 number(Rust 端 f64)正好貼合我們的契約,TS 也會補全。前端用滑桿把值限制在合理範圍(-255~255),就算使用者把參數拉到極端,Rust 端也會再 clamp 一次,保證輸出仍在合法的 0~255。


原本前端部分想把更改的地方一格格說明,但我發現來不及所以算了。就這樣吧 (´_ゝ`)


上一篇
Day 4|第一個影像 API:把一張圖「變灰」
下一篇
Day 6|小小的 pipeline 串起來 o(☆Ф∇Ф☆)o
系列文
把前端加速到天花板:Rust+WASM 即插即用外掛9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言