Image Processing di Browser — WebAssembly

Image Processing dengan Wasm Image processing adalah salah satu use case paling cocok untuk WebAssembly — manipulasi pixel-by-pixel yang compute-intensive…

Image processing di browser artinya: ambil piksel foto dari canvas, hitung piksel baru, lalu gambar lagi. Wasm cocok untuk bagian hitungnya, karena tiap foto berisi jutaan byte yang diproses dengan loop yang sama. Bayangin Bu Sri menyiapkan foto Kamar Teras untuk tiga keperluan: versi hitam-putih untuk brosur fotokopian, versi kecil untuk daftar kamar, dan versi buram untuk latar halaman booking. Fotonya dibawa dari etalase ke meja tukang yang cepat, dikerjakan di sana, lalu dibawa balik. Kalau tukangnya bekerja di ruang belakang, kasir di depan tetap bisa melayani tamu. Etalasenya canvas, meja tukangnya memori Wasm, membawa foto bolak-balik itu menyalin byte, dan ruang belakangnya Web Worker.

🧪 Tebakan cepat: foto 1200 × 800 dikecilkan jadi thumbnail 400 × 267. Dinding bambunya punya bilah selebar 2 piksel, terang dan gelap bergantian. Kalau dikecilkan dengan bilinear (campuran 4 piksel terdekat), dindingnya jadi warna rata, jadi garis-garis palsu, atau sama saja dengan cara nearest? Tebak dulu, jawabannya ada di bagian "Thumbnail 400 × 267".

Alurnya: canvas, memori Wasm, canvas lagi

Pelajaran ini memakai olahfoto.wasm: crate Rust no_std berisi grayscale, tiga cara resize, dan blur, 4.177 byte setelah build release. Semua hitungannya pakai angka bulat, jadi hasilnya sama byte demi byte dengan versi JavaScript yang ada di Lab.

const ctx = canvas.getContext('2d', { willReadFrequently: true });
const img = ctx.getImageData(0, 0, 1200, 800); // RGBA, 3.840.000 byte

WebAssembly.instantiate(bytes).then(({ instance }) => {
  const { memory, grayscale } = instance.exports;
  const ptr = memory.buffer.byteLength;           // taruh foto setelah memori awal modul
  memory.grow(Math.ceil(img.data.length / 65536)); // 59 halaman lagi
  new Uint8Array(memory.buffer).set(img.data, ptr);                // salin masuk
  grayscale(ptr, img.data.length);                                 // hitung di Wasm
  img.data.set(new Uint8Array(memory.buffer, ptr, img.data.length)); // salin keluar
  ctx.putImageData(img, 0, 0);
});

Kenapa Wasm cocok di sini: foto ini 960.000 piksel, dan tiap piksel butuh perkalian dan penjumlahan yang sama. Itu murni kerja CPU. Wasm membaca dan menulis byte langsung di linear memory, tanpa objek dan tanpa garbage collector, dan hasil compile-nya stabil di semua browser.

Grayscale, sekarang dengan SIMD

Rumusnya sama dengan pelajaran sebelumnya: abu = (r × 77 + g × 150 + b × 29) >> 8. SIMD (single instruction, multiple data) mengerjakan satu instruksi untuk 16 byte sekaligus, jadi 4 piksel per langkah. Wasm punya SIMD 128-bit sejak Chrome 91, Firefox 89, dan Safari 16.4.

#[unsafe(no_mangle)]
#[target_feature(enable = "simd128")]
pub unsafe extern "C" fn grayscale_simd(ptr: *mut u8, len: usize) {
    use core::arch::wasm32::*;
    let bobot = i16x8(77, 150, 29, 0, 77, 150, 29, 0);
    let alfa = u32x4_splat(0xFF00_0000);
    let tiga = u32x4_splat(0x0001_0101); // abu × 0x010101 = abu di byte R, G, B
    let mut i = 0;
    while i + 16 <= len {
        let p = unsafe { ptr.add(i) } as *mut v128;
        let v = unsafe { v128_load(p) };
        let a = i32x4_dot_i16x8(u16x8_extend_low_u8x16(v), bobot);
        let b = i32x4_dot_i16x8(u16x8_extend_high_u8x16(v), bobot);
        let jumlah = i32x4_add(i32x4_shuffle::<0, 2, 4, 6>(a, b), i32x4_shuffle::<1, 3, 5, 7>(a, b));
        let abu = u32x4_shr(jumlah, 8);
        unsafe { v128_store(p, v128_or(i32x4_mul(abu, tiga), v128_and(v, alfa))) };
        i += 16;
    }
    unsafe { grayscale(ptr.add(i), len - i) }; // sisa byte: versi biasa
}

Diukur di laptop yang sedang sibuk, median, dalam milidetik (Chromium 147 / WebKit 26.4):

Grayscale 1200 × 800 Waktu
JavaScript 1,95 / 1,13
Wasm 0,85 / 0,88
Wasm, SIMD ditulis tangan 0,47 / 0,44
Wasm, SIMD dari compiler 0,84 / 0,93

Ketiga versi Wasm hasilnya sama persis dengan JavaScript. Modul yang berisi instruksi SIMD gagal di-compile di engine tanpa SIMD, jadi cek dulu dengan WebAssembly.validate pada modul kecil berisi SIMD (cara yang dipakai library wasm-feature-detect), lalu sediakan dua build.

Thumbnail 400 × 267: nearest, bilinear, rata-rata area

1200 dibagi 400 pas 3, jadi pusat tiap piksel thumbnail jatuh tepat di piksel asli 3x + 1. Bobot bilinear arah datarnya 0, dan hasilnya hampir sama dengan nearest: cuma 7.304 dari 427.200 byte yang beda. Dinding bambu (bilah 2 piksel, nilai merah 150 dan 105) diambil tiap 3 piksel, dan muncul garis-garis palsu yang lebarnya 3 kali garis asli. Itu jawaban tebakannya: bilinear cuma melihat 4 tetangga, jadi untuk mengecilkan 3 kali dia gak lebih baik dari nearest. Gejala ini namanya aliasing (moiré).

Cara Chromium 147 Naik-turun warna di dinding
Nearest, JS / Wasm 0,21 / 0,08 ms 22,5 (sama dengan aslinya)
Bilinear, JS / Wasm 3,02 / 0,87 ms 22,5
Rata-rata area, JS / Wasm 5,62 / 2,07 ms 7,5
Blur σ 1,5 dulu, lalu bilinear 3,0

"Naik-turun warna" adalah simpangan baku nilai merah di area dinding: makin kecil, makin rata, makin benar. Untuk mengecilkan banyak, rata-ratakan atau blur dulu.

Blur: kernel yang dihitung, dua langkah

Blur Gaussian mengganti tiap piksel dengan rata-rata berbobot tetangganya. Bobotnya dihitung dari σ (sigma, lebar blur):

function kernelGauss(sigma) {
  const r = Math.ceil(sigma * 3); // tetangga sejauh 3σ
  const w = [];
  let total = 0;
  for (let i = -r; i <= r; i++) {
    const v = Math.exp(-(i * i) / (2 * sigma * sigma));
    w.push(v);
    total += v;
  }
  const k = w.map((v) => Math.round((v / total) * 4096)); // bobot bulat, jumlahnya 4096
  k[r] += 4096 - k.reduce((a, b) => a + b, 0);           // sisa pembulatan masuk ke tengah
  return { k, r };
}
// kernelGauss(2).k = [9, 36, 111, 266, 496, 722, 816, 722, 496, 266, 111, 36, 9]
Blur Gaussian σ 2 Chromium 147 WebKit 26.4
JavaScript 155 ms 127 ms
Wasm 49 ms 49 ms
Wasm, build +simd128 37 ms 39 ms

Di sini Wasm 2,6 sampai 3,2 kali lebih cepat, karena hitungannya jauh lebih banyak daripada ongkos salinnya.

Worker dan OffscreenCanvas: UI tetap jalan

Blur 49 ms di thread utama berarti halaman beku 49 ms: klik dan scroll menunggu. Pindahkan hitungannya ke Web Worker, dan kirim byte-nya sebagai transferable: ArrayBuffer-nya dipindahkan, bukan disalin.

// halaman
const worker = new Worker('olah-worker.js');
worker.onmessage = (e) => ctx.putImageData(new ImageData(new Uint8ClampedArray(e.data), 1200, 800), 0, 0);
const img = ctx.getImageData(0, 0, 1200, 800);
worker.postMessage({ pixels: img.data.buffer, sigma: 2 }, [img.data.buffer]); // dipindah

// olah-worker.js: pasang olahfoto.wasm sekali, blur, kirim balik dengan transfer juga
onmessage = (e) => {
  const hasil = blurDenganWasm(e.data.pixels, e.data.sigma); // ArrayBuffer baru
  postMessage(hasil, [hasil]);
};

Diukur dengan "detak" MessageChannel di thread utama, jeda terpanjangnya (median 5 kali, Chromium 147 / WebKit 26.4):

Blur σ 2 dengan Wasm Jeda terpanjang di thread utama Total (Chromium)
Di thread utama 51 / 51 ms 51 ms
Di Worker, hasil digambar halaman 1,5 / 8,6 ms 54 ms
Di Worker dengan OffscreenCanvas 0,7 / 6,5 ms 53 ms

Totalnya sedikit lebih lama, tapi halaman gak pernah beku. Dengan canvas.transferControlToOffscreen(), canvas-nya diserahkan ke Worker, dan Worker menggambar sendiri. OffscreenCanvas 2D ada di Chrome 69, Firefox 105, dan Safari 16.4.

💡 Kapan gak perlu Wasm

⚠️ Jebakan yang sering ketemu

TL;DR: Alur image processing dengan Wasm: getImageData dari canvas, salin byte RGBA ke linear memory, panggil fungsi Wasm sekali untuk seluruh foto, salin hasilnya keluar, putImageData. Wasm cocok karena kerjanya murni CPU di jutaan byte, dibaca langsung dari memori tanpa garbage collector. Grayscale 1200 × 800 di Chromium 147: JS 1,95 ms, Wasm 0,85 ms, SIMD 0,47 ms (cek dukungan dengan WebAssembly.validate). Thumbnail 400 × 267: nearest dan bilinear sama-sama memunculkan garis palsu, rata-rata area atau blur dulu yang benar. Blur Gaussian: kernel dihitung dari σ (r = ceil(3σ), bobot bulat berjumlah 4096), dikerjakan dua langkah (26 perkalian, bukan 169): JS 155 ms, Wasm 49 ms, +simd128 37 ms. Taruh kerja berat di Worker (jeda thread utama 51 ms jadi 1,5 ms) dan kirim ArrayBuffer sebagai transferable, atau serahkan canvas lewat OffscreenCanvas. Untuk tampilan saja pakai CSS filter, untuk thumbnail biasa createImageBitmap, untuk video WebGL atau WebGPU, dan untuk encoder seperti MozJPEG atau AVIF pakai codec Wasm yang sudah ada (Squoosh, jSquash).

Yang akan kamu pelajari