<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Avinash Kori]]></title><description><![CDATA[Avinash Kori]]></description><link>https://avinashkori.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 16:24:56 GMT</lastBuildDate><atom:link href="https://avinashkori.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building an in-browser AI background remover: licences, WebGPU limits and a 17-buffer surprise]]></title><description><![CDATA[How I built an in-browser AI background remover: model licences, BiRefNet on WebGPU, the 17 storage buffer limit and a CPU fallback.]]></description><link>https://avinashkori.hashnode.dev/building-an-in-browser-ai-background-remover-licences-webgpu-limits-and-a-17-buffer-surprise</link><guid isPermaLink="true">https://avinashkori.hashnode.dev/building-an-in-browser-ai-background-remover-licences-webgpu-limits-and-a-17-buffer-surprise</guid><category><![CDATA[webgpu]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[webdev]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Avinash Kori]]></dc:creator><pubDate>Thu, 29 Oct 2026 10:56:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae3228ed3bd7ee6ea494018/ebbe9221-cc00-4ec6-8e3a-f8a8ee24b5f2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wanted a background remover for <a href="https://devtoolry.com">Devtoolry</a> with one hard rule: <strong>the photo never leaves the browser.</strong> No upload, no server GPU, no "free for 3 images" limit. It sounded like a weekend job with Transformers.js. It took longer, mostly because of licences, memory and one very specific WebGPU limit.</p>
<p>Here is what I learned building it.</p>
<h2>Step 1: pick a model you're actually allowed to use</h2>
<p>Most "remove background" tutorials use <strong>RMBG-1.4 or RMBG-2.0</strong>. They're very good, but their licences are non-commercial, and my site runs ads. <strong>ISNet via @imgly/background-removal</strong> is AGPL, which I didn't want in a closed-source site. That ruled out the usual suspects before I wrote any code.</p>
<p>What I tested next:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Licence</th>
<th>Result in the browser</th>
</tr>
</thead>
<tbody><tr>
<td>MODNet</td>
<td>Apache-2.0</td>
<td>Fast, but it's a <em>portrait</em> model: on a photo of a cat it cut the cat badly</td>
</tr>
<tr>
<td>BiRefNet lite</td>
<td>MIT</td>
<td>Best edges by far, but on the CPU (WASM) it needed several GB of memory and crashed the tab</td>
</tr>
<tr>
<td>ormbg (quantized)</td>
<td>Apache-2.0</td>
<td>Clean on people and on cats, ~44 MB download, ~15 s per photo on a 4-core CPU</td>
</tr>
</tbody></table>
<p>So the plan became: <strong>BiRefNet lite on WebGPU when the GPU can handle it, ormbg on the CPU everywhere else.</strong></p>
<h2>Step 2: run the model in a worker, not on the page</h2>
<p>Inference blocks for seconds, so everything lives in a module Web Worker. Transformers.js does the heavy lifting:</p>
<pre><code class="language-js">const { pipeline } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js');

const segmenter = await pipeline('image-segmentation', 'onnx-community/ormbg-ONNX', {
  device: 'wasm',
  dtype: 'quantized',
  progress_callback: (e) =&gt; {
    if (e.status === 'progress_total') postMessage({ type: 'progress', loaded: e.loaded, total: e.total });
  },
});
</code></pre>
<p>The model files come from the Hugging Face Hub once and Transformers.js keeps them in Cache Storage, so the second visit starts without a download.</p>
<p>One gotcha cost me an hour: <strong>set</strong> <code>onmessage</code> <strong>before any top-level</strong> <code>await import()</code> <strong>in the worker.</strong> If the import comes first, the page's first message arrives while the module is still loading and is silently lost.</p>
<h2>Step 3: don't feed a 12-megapixel photo to the model</h2>
<p>Phone photos are huge, and segmentation models work at roughly 1024 px anyway. The page:</p>
<ol>
<li>Draws a copy of the image scaled down to at most 1024 px on its longest side.</li>
<li>Sends only those pixels to the worker (as a transferable buffer, no copy).</li>
<li>Gets back a <strong>one-channel mask</strong> at that small size.</li>
<li>Scales the mask up to the original size and applies it as the alpha channel of the <em>original</em> pixels.</li>
</ol>
<p>The result keeps the full resolution (a 2400×3000 photo comes back as a 2400×3000 PNG), while the model only ever sees a 1024 px image. The mask is soft at the edges after upscaling, which looks natural around hair.</p>
<h2>Step 4: the WebGPU limit nobody mentions</h2>
<p>My dev server has no real GPU, so the WebGPU path couldn't be tested properly there. The first time I ran it on a normal desktop GPU, the very first image failed with:</p>
<pre><code class="language-text">Too many storage buffers in shader. Current: 17, Max is 16
</code></pre>
<p>The BiRefNet ONNX export has a shader that binds <strong>17 storage buffers</strong>. WebGPU exposes the limit as <code>maxStorageBuffersPerShaderStage</code>, and many GPUs cap it at 16. ONNX Runtime already asks for the adapter's maximum, so there was nothing to raise. On those cards this model simply can't run.</p>
<p>The fix is to ask the adapter <em>before</em> downloading 115 MB of model:</p>
<pre><code class="language-js">const BIREFNET_MIN_STORAGE_BUFFERS = 17;

async function choose(cpuOnly) {
  const cpu = { model: 'onnx-community/ormbg-ONNX', device: 'wasm', dtype: 'quantized' };
  if (cpuOnly) return cpu;
  const adapter = await navigator.gpu?.requestAdapter().catch(() =&gt; null);
  if (!adapter || adapter.limits.maxStorageBuffersPerShaderStage &lt; BIREFNET_MIN_STORAGE_BUFFERS) return cpu;
  // fp16 halves the download (115 MB instead of 224 MB) but needs shader-f16.
  return {
    model: 'onnx-community/BiRefNet_lite-ONNX',
    device: 'webgpu',
    dtype: adapter.features.has('shader-f16') ? 'fp16' : 'fp32',
  };
}
</code></pre>
<p>Two more lessons from that bug:</p>
<ul>
<li><strong>A failed WebGPU run can poison ONNX Runtime for the rest of the worker.</strong> After one GPU error, later runs in the same worker kept failing, even on the CPU backend. So the worker doesn't retry. It reports <code>gpu: true</code>, the page terminates it, starts a fresh CPU-only worker and sends the image again.</li>
<li><strong>Remember the failure.</strong> The page stores a <code>cpu-only</code> flag in <code>localStorage</code>, so the next visit goes straight to the model that works instead of failing once more.</li>
</ul>
<p>To be honest about the outcome: on my GPU, and on many common cards, the limit is 16, so many visitors get ormbg on the CPU.</p>
<h2>Step 5: let people fix what the model gets wrong</h2>
<p>No model is perfect. ormbg sometimes keeps a soft shadow on a plain wall behind a person, and it does so <em>confidently</em> (mask values around 253 of 255), so no threshold can remove it without eating into hair as well.</p>
<p>Instead of fighting the model, I added an <strong>Erase / Restore brush</strong>: paint over the shadow to erase it, or paint back an earring the model dropped. The brush edits the mask, not the pixels, so restoring brings back the original image exactly. Two minutes of touch-up beats any amount of threshold tuning.</p>
<h2>What I'd tell someone building the same thing</h2>
<ol>
<li><strong>Check the licence first.</strong> The best-known models aren't usable on a site with ads.</li>
<li><strong>Test on non-people images.</strong> Portrait models fail on pets and products.</li>
<li><strong>Downscale for the model, upscale the mask.</strong> You get full-resolution output for the cost of a 1024 px inference.</li>
<li><strong>Check</strong> <code>adapter.limits</code> <strong>before choosing a WebGPU model,</strong> and treat a GPU error as "start a new worker", not "retry".</li>
<li><strong>Give users a brush.</strong> It turns "almost right" into "right".</li>
</ol>
<p>You can try it here: <a href="https://devtoolry.com/image-tools/background-remover">free AI background remover</a>. The model is cached after the first visit, and nothing is uploaded. If your GPU reports 17 or more storage buffers and BiRefNet runs for you, I'd love to know which card it is.</p>
]]></content:encoded></item></channel></rss>