<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Umasou</title>
    <description>The latest articles on DEV Community by Umasou (@sharksevenro).</description>
    <link>https://dev.to/sharksevenro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4153305%2F9adf83b7-6548-42be-978e-9048e5fbd88e.jpg</url>
      <title>DEV Community: Umasou</title>
      <link>https://dev.to/sharksevenro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharksevenro"/>
    <language>en</language>
    <item>
      <title>Automatic Watermark Detection: What 500 Real Cases Changed</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:02:25 +0000</pubDate>
      <link>https://dev.to/sharksevenro/automatic-watermark-detection-what-500-real-cases-changed-1jh4</link>
      <guid>https://dev.to/sharksevenro/automatic-watermark-detection-what-500-real-cases-changed-1jh4</guid>
      <description>&lt;p&gt;We tested automatic watermark detection on 500 real AI-platform frames: editable-selection coverage rose from 75.8% to 95.0%. A separate 36-image human-mask evaluation still measured only 47.1% precision and 11.0% recall. Those results are not contradictory: locating a useful corner box is much easier than selecting exactly the watermark pixels.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;What it actually measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real platform frames with a selection&lt;/td&gt;
&lt;td&gt;379 / 500 (75.8%)&lt;/td&gt;
&lt;td&gt;475 / 500 (95.0%)&lt;/td&gt;
&lt;td&gt;Whether Auto Detect leaves something editable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frames with no selection&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;Coverage failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime errors&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Batch stability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-labeled mask precision&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;47.1%&lt;/td&gt;
&lt;td&gt;Selected pixels that match human masks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-labeled mask recall&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;11.0%&lt;/td&gt;
&lt;td&gt;Human-mask pixels recovered by Auto Detect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-labeled IoU&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;9.8%&lt;/td&gt;
&lt;td&gt;Pixel intersection over union&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last three rows are the warning label. This work made Auto Detect more useful; it did not make automatic masks exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the job, not the model leaderboard
&lt;/h2&gt;

&lt;p&gt;A person using a watermark remover wants the mark gone. They do not want to learn the difference between object detection, segmentation and inpainting before they can click Remove.&lt;/p&gt;

&lt;p&gt;Our original pipeline already had three useful parts. A detector proposed boxes. Text heuristics produced glyph-shaped masks. LaMa or MI-GAN rebuilt the selected area. We added SlimSAM because a promptable segmenter can turn a rough click or box into an object-shaped selection. In manual use, that worked well: a person could click a mark, reject a bad candidate and add another prompt.&lt;/p&gt;

&lt;p&gt;Auto Detect exposed a different problem. A detector box is not a segmentation prompt with known intent. It may contain a watermark, part of the scene, or both. Feeding every box to SAM simply gave us a more expensive way to select the wrong object.&lt;/p&gt;

&lt;p&gt;The first rule became conservative: automatic selection should act only when several independent signals agree. If they do not, leave the target for a manual click, box or brush.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first pipeline was too willing to paint
&lt;/h2&gt;

&lt;p&gt;The initial combined route tried SAM candidates and glyph masks, then fell back to boxes. On a 26-image development set, an early full run reached 31.3% recall but only 6.5% precision. Large false-positive areas dominated the result. Eleven cases also failed in that early evaluator, so even its aggregate numbers were not a clean product baseline.&lt;/p&gt;

&lt;p&gt;Filtering alone did not fix it. A more conservative run raised precision to 13.7%, with 34.1% recall. That trade was still unusable: too much scenery was being selected.&lt;/p&gt;

&lt;p&gt;We then required the automatic mask to pass several geometric checks. SAM pixels had to intersect the detector box, remain mostly contained by it, overlap available glyph evidence, and stay below both box-relative and whole-image area limits. Text masks used the intersection of glyph and local-stroke evidence rather than their union. The 26-image run reached 57.7% precision and 19.9% recall, with 12 of 26 cases receiving an automatic mask.&lt;/p&gt;

&lt;p&gt;That looked like progress until we evaluated a different set. On the 36 human-annotated images, Auto Detect measured 47.1% precision, 11.0% recall and 9.8% IoU. Only one case met the evaluator’s complete-case criterion. A threshold tuned on a small development set had not become a general solution.&lt;/p&gt;

&lt;p&gt;This is why the &lt;a href="https://clearpix.org/blog/automatic-watermark-detection-yolo-ocr-browser" rel="noopener noreferrer"&gt;original YOLO, OCR and glyph-mask design&lt;/a&gt; still matters, but cannot be the end of the pipeline. It supplies evidence about where text might be. It does not prove which pixels belong to an overlay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use SAM where its answer can be checked
&lt;/h2&gt;

&lt;p&gt;SAM now runs automatically only for candidates with supporting structure: similar detected boxes or solid watermark-like regions. A lone weak text proposal does not automatically trigger segmentation.&lt;/p&gt;

&lt;p&gt;For each accepted candidate, we check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at least 5% of the detector box is covered;&lt;/li&gt;
&lt;li&gt;at least 60% of the candidate stays inside that box;&lt;/li&gt;
&lt;li&gt;text candidates have at least 35% overlap with glyph evidence;&lt;/li&gt;
&lt;li&gt;the candidate is no larger than twice the box area;&lt;/li&gt;
&lt;li&gt;the candidate covers no more than 20% of the whole image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are guards, not a measure of semantic correctness. They reject obvious spills and disconnected scene selections. They cannot prove that a white letter is a watermark rather than a road marking or clothing print.&lt;/p&gt;

&lt;p&gt;The conservative text path is stricter still. It requires detection quality of at least 0.95, enough glyph pixels, sensible fill and containment, limited background complexity, and a text-like aspect ratio. When those conditions fail, it returns no bitmap rather than silently substituting a large rectangle.&lt;/p&gt;

&lt;p&gt;That behavior follows the lesson from our &lt;a href="https://clearpix.org/blog/auto-detect-precision-simple-backgrounds" rel="noopener noreferrer"&gt;earlier false-positive cleanup&lt;/a&gt;: a missed automatic selection can be repaired by the user, while an automatic mask over the main subject can ruin the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Find Similar” needed evidence too
&lt;/h2&gt;

&lt;p&gt;Repeated marks looked like an easy win. Select one watermark, match its gray-level and edge pattern in other detector boxes, then translate the accepted mask to matching positions.&lt;/p&gt;

&lt;p&gt;The first attempts were not good enough. On a difficult five-case repeated-mark set, the evaluated variants selected large incorrect areas; precision stayed around 1–2%. Template similarity can be fooled by repeated texture, and a translated mask inherits the shape and alignment errors of the source.&lt;/p&gt;

&lt;p&gt;We kept the capability but narrowed its job. Similar-box detection compares source type, watermark type, dimensions and masked edge appearance. The interface offers Find Similar only when at least three similarly sized candidates support the pattern. It is an optional way to reduce repeated clicking, not proof that every matching patch should be removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second segmentation model did not rescue the metrics
&lt;/h2&gt;

&lt;p&gt;We also tested a dedicated watermark-segmentation model on the human-labeled set. At a 0.3 threshold it reached 65.0% precision and 31.4% recall—better than the automatic hybrid on those metrics, but still short of the “selected means correct” behavior we wanted.&lt;/p&gt;

&lt;p&gt;A two-model intersection did not reproduce the strong synthetic-set result that motivated the experiment. On the human set, its best listed operating points were around 65–66% precision with lower recall as thresholds rose. A lightweight student-model distillation route produced no usable Golden Set output and was not integrated.&lt;/p&gt;

&lt;p&gt;Those experiments were worth doing because they closed off an attractive shortcut. Adding another model or taking an intersection is not automatically a precision strategy. The evaluation set decides whether the idea survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 500-frame batch changed a different bottleneck
&lt;/h2&gt;

&lt;p&gt;The human set is good for pixel truth. It is too small to describe the variety of platform marks people actually bring to the tool. We therefore built a separate batch runner for 500 frames from AI video platforms, with per-brand, position and style summaries, mask RLE output, resume support and screenshot capture.&lt;/p&gt;

&lt;p&gt;This runner measures selection coverage, not pixel precision. It also checks that the red pixels used for measurement are new overlay pixels rather than red content already present in the source.&lt;/p&gt;

&lt;p&gt;The largest remaining coverage problem was not SAM making bad masks. It was the pipeline returning nothing when no exact mask passed. For a single detector box, we already kept an editable box fallback. We extended that idea to multiple candidates, but only for a high-confidence, small edge region:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;detection quality at least 0.95;&lt;/li&gt;
&lt;li&gt;box center within 0.15 normalized units of an image edge;&lt;/li&gt;
&lt;li&gt;box area no larger than 8% of the image;&lt;/li&gt;
&lt;li&gt;candidates ranked by detection quality plus an edge-position bonus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is deliberately a box, not a claim of exact segmentation. A person can move it, resize it or switch to Smart, Paint or Rect before removal.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform group&lt;/th&gt;
&lt;th&gt;Frames&lt;/th&gt;
&lt;th&gt;Selected after the change&lt;/th&gt;
&lt;th&gt;No selection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Alice AI&lt;/td&gt;
&lt;td&gt;133&lt;/td&gt;
&lt;td&gt;123 (92.5%)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dola AI&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;69 (100%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Luma AI&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;42 (91.3%)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All platform and generic marks&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;475 (95.0%)&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Offline candidate-ranking checks agreed with the expected watermark region for 437 of 451 selected candidates, or 96.9%. That number describes location metadata. It is not a substitute for the human-mask precision result above.&lt;/p&gt;

&lt;p&gt;The remaining 25 no-selection frames mainly contained low-confidence proposals, oversized boxes, unusual positions or complete detection misses. We left them unselected rather than lowering every threshold to make the coverage chart reach 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for the product
&lt;/h2&gt;

&lt;p&gt;Auto Detect is now a first pass with visible limits. It can produce a precise mask when the evidence agrees, or an editable box when a small high-confidence edge target needs review. It can also return nothing. Smart selection, a rectangle and a brush remain part of the same workflow rather than hidden fallbacks.&lt;/p&gt;

&lt;p&gt;For video workflows built around stable platform overlays, we also published focused preset pages for &lt;a href="https://clearpix.org/remove-alice-ai-watermark" rel="noopener noreferrer"&gt;Alice AI watermark removal&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-dola-ai-watermark" rel="noopener noreferrer"&gt;Dola AI watermark removal&lt;/a&gt; and &lt;a href="https://clearpix.org/remove-luma-ai-watermark" rel="noopener noreferrer"&gt;Luma Dream Machine watermark removal&lt;/a&gt;. Their sample data shows why presets need review: all sampled Alice and Dola marks were bottom-right, while only 80.4% of Luma marks were upper-left.&lt;/p&gt;

&lt;p&gt;The same sample now supports preset guides for &lt;a href="https://clearpix.org/remove-deevid-ai-watermark" rel="noopener noreferrer"&gt;DeeVid AI&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-dreamina-ai-watermark" rel="noopener noreferrer"&gt;Dreamina AI / Jimeng&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-pollo-ai-watermark" rel="noopener noreferrer"&gt;POLLO AI&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-kling-ai-watermark" rel="noopener noreferrer"&gt;Kling AI&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-hailuo-ai-watermark" rel="noopener noreferrer"&gt;Hailuo AI / MiniMax&lt;/a&gt;, &lt;a href="https://clearpix.org/remove-vidu-ai-watermark" rel="noopener noreferrer"&gt;Vidu AI&lt;/a&gt; and &lt;a href="https://clearpix.org/remove-digen-ai-watermark" rel="noopener noreferrer"&gt;DIGEN AI&lt;/a&gt;. Each page addresses its actual layout: Dreamina has two independently editable marks, POLLO includes the solid backing plate, and Hailuo accounts for a longer bottom-edge wordmark.&lt;/p&gt;

&lt;p&gt;The next improvement is not a blanket lower threshold. We need more human masks across low-contrast text, solid badges and repeated marks, then per-type operating points with false-positive cost attached. Coverage tells us how often the tool offers a starting point. Human-labeled precision tells us whether that starting point can be trusted.&lt;/p&gt;

&lt;p&gt;Try the current workflow in the &lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;image watermark remover&lt;/a&gt;, or use the &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;video watermark remover&lt;/a&gt; for fixed overlays. Both run locally in the browser; review the mask before removing, and add missed areas manually.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 21 of the ClearPix engineering series. Evaluation code: &lt;code&gt;apps/web/scripts/eval-auto-select-quality.mjs&lt;/code&gt; and &lt;code&gt;apps/web/scripts/eval-watermark-batch.mjs&lt;/code&gt;. The 500-frame snapshot is a fixed internal QA sample; its coverage figures are not presented as public benchmark accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>testing</category>
    </item>
    <item>
      <title>Remove Watermarks Without Uploading: How We Built an In-Browser AI Editor</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Sun, 01 Nov 2026 11:56:13 +0000</pubDate>
      <link>https://dev.to/sharksevenro/remove-watermarks-without-uploading-how-we-built-an-in-browser-ai-editor-55pp</link>
      <guid>https://dev.to/sharksevenro/remove-watermarks-without-uploading-how-we-built-an-in-browser-ai-editor-55pp</guid>
      <description>&lt;p&gt;&lt;em&gt;This post is cross-posted from the &lt;a href="https://clearpix.org/blog/private-in-browser-watermark-removal-architecture" rel="noopener noreferrer"&gt;ClearPix engineering blog&lt;/a&gt;. ClearPix is free and open at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;; the watermark-removal core described here is MIT-licensed and on GitHub at &lt;a href="https://github.com/SharksevenRo/browser-watermark-removal" rel="noopener noreferrer"&gt;SharksevenRo/browser-watermark-removal&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  Remove Watermark Without Uploading: How We Built an In-Browser AI Editor
&lt;/h1&gt;

&lt;p&gt;Most online watermark removers ask you to hand over your photo first and ask questions later. We built ClearPix the other way around: the AI models come to your device, your files never leave it, and inference runs entirely in your browser tab. This post is the architecture overview of how that actually works in production — the ONNX Runtime Web + WebGPU inference stack, the model distribution pipeline (R2 CDN, multi-mirror fallback, SHA-256 self-healing cache), the places it broke on us, and how we keep the privacy claim structural instead of rhetorical. No marketing math; every number below comes from our own engineering logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a watermark remover upload your photos? Usually, yes
&lt;/h2&gt;

&lt;p&gt;The upload question is not paranoia — it is the default architecture of the category. Nearly every "free online watermark remover" is a thin frontend over a server-side inference API. Your image goes up, gets processed on someone else's GPU, and comes back down. Whether the privacy policy says the file is deleted afterward, you have no way to verify it, and neither do we. The structural fact is that your media crossed the wire.&lt;/p&gt;

&lt;p&gt;We kept running into this as a genuine user anxiety, and we shared it. Photos with watermarks are often personal — screenshots, ID documents, family pictures, client work. "Trust us, we delete it" is a weak foundation for a tool people use on sensitive files. So we asked the harder question: can the inference just happen on the user's machine?&lt;/p&gt;

&lt;p&gt;The answer, in 2026, is yes — if you are willing to solve the distribution and reliability problems that server-side tools offload to a data center. That trade is the whole story of ClearPix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture in one paragraph
&lt;/h2&gt;

&lt;p&gt;ClearPix is a static site. There is no processing backend. When you open a tool page, your browser downloads ONNX model weights (once, then cached forever), loads them into ONNX Runtime Web, and runs inference on WebGPU where available with a WASM fallback. Your image or video is decoded locally, processed locally, re-encoded locally, and never serialized into a network request. The only bytes that travel are the models coming down to you, and a small set of aggregate analytics events that contractually cannot contain your media.&lt;/p&gt;

&lt;p&gt;That single paragraph hides about 90% of the engineering. Let's unpack it.&lt;/p&gt;

&lt;h2&gt;
  
  
  In-browser AI image processing: the runtime layer
&lt;/h2&gt;

&lt;p&gt;We run all neural inference through ONNX Runtime Web (ort 1.30.x), loaded via dynamic import so its ~1 MB parsed weight stays out of the initial page bundle — Core Web Vitals matter even for tools. Session creation prefers the WebGPU execution provider and falls back to WASM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;modelBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgpu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getOrt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ... try each execution provider in order, return first that works&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebGPU is worth real money here: when we enabled the WebGPU EP (ort 1.30+), our inpainting step went from 2100–3500 ms to roughly 400 ms per region in our benchmarks, and in October 2026 the HQ LaMa path finally joined it at 0.86 s per 512×512 tile. But "session created" does not mean "inference works" — we have seen WebGPU runs fail in ways that looked environmental and were not. So the session runs behind a watchdog, and a dead WebGPU session falls back to WASM rather than hanging the tab. The fallback chain is not an afterthought; every production stall we have ever shipped was closed by one. (One of those "deadlocks" turned out to be a deterministic one-element &lt;code&gt;Slice&lt;/code&gt; bug in ORT's default build — see &lt;a href="https://clearpix.org/blog/lama-webgpu-slice-off-by-one" rel="noopener noreferrer"&gt;part 14&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Even the runtime binaries need their own distribution strategy. The WebGPU build we import now depends on &lt;code&gt;ort-wasm-simd-threaded.asyncify.wasm&lt;/code&gt; (25.5 MB raw, 3.8 MiB after brotli) — if the runtime fails to load, ORT silently drops back to CPU inference and the user just experiences a mysteriously slow tool. We serve the ORT runtime from jsDelivr first (measured ~7× the throughput of our own R2 custom domain for these files) with a 5-second HEAD probe, falling back to our self-hosted R2 copy when the CDN is unreachable — which happens in some network environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  A client-side watermark remover lives or dies by its model pipeline
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable truth of client-side AI: you are now a software distribution company. A server-side tool updates weights by deploying a container. We have to push tens of megabytes of model files to arbitrary browsers on arbitrary networks, keep them intact, and never strand a user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution.&lt;/strong&gt; Models live in a Cloudflare R2 bucket behind a custom domain on our zone. This was a measured decision, not a default: direct &lt;code&gt;r2.dev&lt;/code&gt; URLs gave us 75–110 KB/s, while the same-zone custom domain delivered 2.3 MB/s — a 20–30× difference that turns a model download from a coffee break into a pause. Models are also deliberately excluded from our deploy bundle; stripping them (plus internal bench assets) took our release zip from 27.4 MB down to 10 MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permanent caching.&lt;/strong&gt; Once downloaded, model bytes go into IndexedDB and are reused forever — repeat visits load from the device, and the tool works offline. Two details cost us real debugging time. First, big models are written in 32 MB chunks (above a 64 MB threshold), because a single structured clone of the 198 MB fp32 LaMa pushed the renderer process into OOM (the HQ weights are fp16 now, but the chunking rule predates that). Second, the cache is self-healing: we verify the SHA-256 of a cached model on every hit (~100 ms for a 67 MB file), because a download interrupted mid-write would otherwise poison a cache that never expires — permanently bricking that user's tool with no visible error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mirrors and fallback specs.&lt;/strong&gt; Each model spec carries an ordered mirror list, a pinned SHA-256, and an optional fallback spec with a different weight format (fp16 → fp32) and an independent cache key. The first mirror whose bytes hash-match wins; a stalled connection (no bytes for 30 seconds) is aborted and handed to the next mirror; if every mirror fails, the fallback spec takes over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchWithProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;integrity check failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;idbSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// best-effort&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lastErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// next mirror&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We learned this the hard way: on 2026-09-26 a mirror-chain failure took model loading down in production. Since then the rule is R2 first, public mirrors as backup, fallback spec chain underneath — designed before launch, not patched after an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the privacy claim structural
&lt;/h2&gt;

&lt;p&gt;"Private photo editor, no upload" is easy to print on a landing page. We wanted it to be checkable from inside the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Privacy Shield.&lt;/strong&gt; Every tool page renders a live status bar: a "0 bytes uploaded" chip, per-model download state with an offline-ready badge, and — once an inference session exists — the active backend translated into plain language. It is driven by a status bus inside the model loader, so it reflects what the engine is actually doing, not what the copywriter hoped. You can open devtools and verify the claim yourself: the network tab shows model bytes coming in and nothing going out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event contract we publish.&lt;/strong&gt; We do collect a small set of aggregate events to know whether the tools work — things like &lt;code&gt;process_complete&lt;/code&gt; (duration, backend, device tier) and &lt;code&gt;result_feedback&lt;/code&gt; (thumbs up/down with reason tags). The contract, stated in our privacy policy, is that events never include images, file names, or file contents. Including them would defeat the point of the site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics that can't leak media.&lt;/strong&gt; We use Microsoft Clarity for session replays to fix confusing UI. Because your files never reach a server this is already low-risk, but we additionally mark every canvas, image, and video element that can display user media with Clarity's &lt;code&gt;data-clarity-mask&lt;/code&gt; attribute, so replays are structurally incapable of containing your content even if a selector goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this architecture costs us
&lt;/h2&gt;

&lt;p&gt;Honesty section, because dev.to deserves one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First-visit cost is real.&lt;/strong&gt; Downloading models and compiling WebGPU shaders makes the first run slower than a server round-trip. We hide shader compilation and encoder init behind prefetch and warmup, and IndexedDB makes visit two effectively free — but visit one pays the toll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We gave up control of the hardware.&lt;/strong&gt; A data center GPU is a known quantity; a user's browser is not. WebGPU flakiness, missing cross-origin isolation (which caps WASM threading), and mobile memory limits are all our problem now. The fallback chains exist because each of these has bitten us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice is constrained by the browser.&lt;/strong&gt; We pick architectures designed for the deployment environment over paper SOTA — swapping our upscaler to a compact architecture was a 10× per-frame win at equal-or-better quality, something no amount of tuning the heavyweight model would have produced. That is a full post on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We think the trade is overwhelmingly worth it, but it is a trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything described above is live and free, no account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;Remove a watermark from a photo&lt;/a&gt; — detection, inpainting, and export all in-tab&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;Our free video watermark remover&lt;/a&gt; — streaming pipeline, up to two-minute clips&lt;/li&gt;
&lt;li&gt;&lt;a href="https://clearpix.org/remove-subtitles-from-video" rel="noopener noreferrer"&gt;Remove hardcoded subtitles from a video&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;Upscale an image 4×&lt;/a&gt; — the compact SR model mentioned above (video upscaling lives on the same stack)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next in this series
&lt;/h2&gt;

&lt;p&gt;This overview skipped the interesting fights. The rest of the series goes deep on each one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The video pipeline rewrite&lt;/strong&gt; — how replacing ffmpeg.wasm with WebCodecs + MediaBunny took full video processing from 95 s to 9 s, and how a streaming single-pass pipeline got us to O(1) memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model selection and quantization in the browser&lt;/strong&gt; — why fp16 was free bandwidth for our upscaler and, as we originally wrote it, destroyed our inpainting model (mask-gated architectures and precision sensitivity) — &lt;strong&gt;corrected&lt;/strong&gt;: the real cause turned out to be a converter overflow bug, not the architecture, and the fp16 model now matches fp32 while downloading 52% smaller; see &lt;a href="https://clearpix.org/blog/fp16-quantization-quality-loss-overflowed-tensor" rel="noopener noreferrer"&gt;part 13&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video inpainting performance&lt;/strong&gt; — motion-adaptive frame skipping lets 65% of frames avoid inference entirely, plus the temporal EMA that keeps the result flicker-free (and the UX change that accidentally switched the whole optimization off).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running a ~100–200MB model in a browser tab&lt;/strong&gt; — tiles, memory walls, the watchdog/fallback structure, and the optimizations we benchmarked and killed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic watermark detection&lt;/strong&gt; — a YOLO + OCR dual-engine detector and the glyph-level masks that raised precision without losing recall.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Part 1 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Can a General Real-ESRGAN Model Handle Anime? Our 45-Case Browser Baseline</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:48:00 +0000</pubDate>
      <link>https://dev.to/sharksevenro/can-a-general-real-esrgan-model-handle-anime-our-45-case-browser-baseline-jl9</link>
      <guid>https://dev.to/sharksevenro/can-a-general-real-esrgan-model-handle-anime-our-45-case-browser-baseline-jl9</guid>
      <description>&lt;p&gt;The answer is yes—with a qualification that matters more than the headline: ClearPix's current &lt;code&gt;realesr-general-x4v3&lt;/code&gt; pipeline produced usable anime enlargements, but it also softened faint lines, small accessories, and screentone. We are keeping that model for the first anime tool page. We are not calling it an anime-specific model, and we are not calling the output preservation-grade.&lt;/p&gt;

&lt;p&gt;The suite contains 15 modern anime and illustration sources, each tested as clean, JPEG70, and JPEG40 input: 45 runs through the production ONNX Runtime Web pipeline. The average full-image PSNR was 23.581 dB and SSIM 0.867. Those numbers are useful for spotting regressions. They are not a quality verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  We had to fix the ruler first
&lt;/h2&gt;

&lt;p&gt;Our first run produced impossible values, including NaN and extremely low PSNR on transparent JPEG cases. The model had not suddenly collapsed. The benchmark was wrong in two separate ways.&lt;/p&gt;

&lt;p&gt;Some source dimensions were not divisible by four. Downsampling with rounded dimensions and upscaling by four produced a result one or two pixels larger than the reference, so pixels were compared at the wrong positions. The generator now makes every reference dimension a multiple of four before creating the input.&lt;/p&gt;

&lt;p&gt;Transparent sources created a second problem. JPEG inputs were flattened on white, while the transparent reference still contained RGB values in invisible pixels. Comparing those RGB arrays directly measured a background-encoding difference rather than model quality. PSNR, SSIM, and high-frequency metrics now use images composited on white. Alpha is evaluated separately.&lt;/p&gt;

&lt;p&gt;That alpha metric compares the output with a 4× resampling of the &lt;strong&gt;input&lt;/strong&gt; alpha. Comparing a JPEG-flattened input with the original transparent file would be meaningless. After the corrections, all 24 cases with non-opaque source pixels report alpha MAE 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpha was a product bug, not a model choice
&lt;/h2&gt;

&lt;p&gt;The shared upscaler wrote alpha 255 into every output pixel. This made transparent anime PNGs opaque regardless of which super-resolution model we chose.&lt;/p&gt;

&lt;p&gt;We now reconstruct the source alpha at the output dimensions. Fully transparent pixels have their RGB normalized to zero, and the alpha-preservation path applies to both single-shot and tiled inference. This is a shared pipeline fix, so the existing 15-case general upscale suite and the media tools that use it still have to pass regression checks.&lt;/p&gt;

&lt;p&gt;The important lesson is that “use an anime model” would have been a plausible but incomplete answer. File-format behavior can matter as much as model selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the general model worked
&lt;/h2&gt;

&lt;p&gt;Flat colors, clean silhouettes, ordinary character illustrations, and moderately complex wallpapers remained usable across the three input variants. The fine freehand line art stayed coherent. The colored manga portrait retained its hair highlights and composition. The anime-expo wallpaper kept its mecha and background readable despite the expected softness.&lt;/p&gt;

&lt;p&gt;The JPEG results also remind us not to read every higher number as “better.” Compression can remove some high-frequency noise before inference. A cleaner metric may coexist with a visible change in texture. The fixed crops remain the final check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it did not meet a preservation claim
&lt;/h2&gt;

&lt;p&gt;Five sources carry warnings in our review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Screentone:&lt;/strong&gt; the regular dot pattern visibly softens and changes. We cannot claim that dots are preserved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faint line art:&lt;/strong&gt; pale lines and small details become weaker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detailed character with a camera:&lt;/strong&gt; small accessories, hair, and camera details smear more than the large shapes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group wallpaper:&lt;/strong&gt; faces and small costume details soften.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pencil sketch:&lt;/strong&gt; paper and pencil texture are amplified, which may be undesirable when a softer look is wanted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The review was AI-assisted visual inspection of fixed crops, not owner-approved human QA. We record that distinction in the result files. Public review states should never be used to imply that a person has signed off when nobody has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we are not adding a second model today
&lt;/h2&gt;

&lt;p&gt;Real-ESRGAN distributes an anime-specific model. It may be a useful future experiment, but adding it would mean another download, another session lifecycle, another quantization check, and another browser memory budget. A model name is not enough to justify those costs.&lt;/p&gt;

&lt;p&gt;The current model passed the “usable with disclosed boundaries” gate. The next anime page therefore uses the same &lt;code&gt;general-x4v3&lt;/code&gt; pipeline as the main image upscaler. It adds scene-specific guidance, evidence, and the alpha fix—not a fictional new engine.&lt;/p&gt;

&lt;p&gt;If future cases show that the general model fails the tasks users actually care about, an anime-specific candidate will need to beat it on identical inputs, crops, sharpening, runtime, and download size. A win on one wallpaper would not be enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are publishing
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://clearpix.org/quality" rel="noopener noreferrer"&gt;Quality dashboard&lt;/a&gt; now includes the anime suite, its methodology, real crops, metrics, review states, and per-case attribution. JSON and CSV downloads use the same published data.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://clearpix.org/upscale-anime-image" rel="noopener noreferrer"&gt;anime image upscaler&lt;/a&gt; uses that production path directly. It recommends ordinary anime wallpapers, illustrations, and avatars while warning against preservation expectations for screentone, faint lines, and tiny details. The companion &lt;a href="https://clearpix.org/blog/anime-upscaling-test-set-in-public" rel="noopener noreferrer"&gt;test-set article&lt;/a&gt; explains the source licenses and coverage gaps.&lt;/p&gt;

&lt;p&gt;That is the baseline: useful enough to ship honestly, not good enough to stop measuring.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building a Licensed Anime Upscaling Test Set in Public</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:02:40 +0000</pubDate>
      <link>https://dev.to/sharksevenro/building-a-licensed-anime-upscaling-test-set-in-public-561f</link>
      <guid>https://dev.to/sharksevenro/building-a-licensed-anime-upscaling-test-set-in-public-561f</guid>
      <description>&lt;p&gt;Anime upscaling is often demonstrated with one dramatic wallpaper enlargement. That makes a good screenshot and a weak acceptance test. A model can sharpen a character outline beautifully while destroying the flat color beside it, turning a screentone pattern into mud, or painting a dark fringe around a transparent PNG. We wanted a test set that could catch those failures before we built an anime landing page.&lt;/p&gt;

&lt;p&gt;So we started with sources and failure modes, not a new model. We collected 18 real, externally authored images with licenses that permit testing and publication. The first formal suite uses 15 modern anime and illustration sources across clean, JPEG70, and JPEG40 variants—45 deterministic runs. A cosplay photograph, a photo of a printed calendar, and a historic woodblock print stay out of the modern-anime aggregate. They are useful boundary material, but calling them modern anime would make the result easier to market and less honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the set actually contains
&lt;/h2&gt;

&lt;p&gt;The first two collection rounds produced a deliberately mixed set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cel-shaded and flat-color characters, including low-resolution JPEGs;&lt;/li&gt;
&lt;li&gt;fine and faint line art;&lt;/li&gt;
&lt;li&gt;pencil and grayscale texture;&lt;/li&gt;
&lt;li&gt;transparent PNGs;&lt;/li&gt;
&lt;li&gt;detailed digital wallpapers;&lt;/li&gt;
&lt;li&gt;painterly anime-style illustration;&lt;/li&gt;
&lt;li&gt;a manga portrait in grayscale, screentone, and colored variants.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three manga variants share one composition. That is intentional: they let us compare what the same pipeline does to ordinary grayscale shading, a regular dot pattern, and color highlights. They do not count as three independent topics in our coverage claims.&lt;/p&gt;

&lt;p&gt;We also know what is still missing: licensed modern animation frames, more complex natural backgrounds, and explicit gradient banding cases. A test suite with a documented gap is more useful than a claim of universal coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licensing is part of the test fixture
&lt;/h2&gt;

&lt;p&gt;Every source has an entry in our manifest with its author, source page, original file, downloaded version, dimensions, SHA-256, and license. The accepted licenses are public domain, CC0, CC BY, and CC BY-SA. Source review is not a legal warranty, but an image with no traceable origin does not belong in our public benchmark.&lt;/p&gt;

&lt;p&gt;That metadata cannot collapse into one dataset-level license. Different cases carry different authors and terms. Share-alike derivatives need to remain under the same license when we publish them. Resized Wikimedia thumbnails are identified as thumbnails rather than described as original files, and the SVG manga variants are identified as raster renditions.&lt;/p&gt;

&lt;p&gt;This is tedious work. It is also what allows the Quality page, downloadable JSON, CSV, and blog posts to show real inputs instead of mysteriously sourced anime art.&lt;/p&gt;

&lt;h2&gt;
  
  
  The degradation protocol
&lt;/h2&gt;

&lt;p&gt;For the formal suite, each modern source is first resized to fit within 960 pixels on its longest side. That keeps the browser benchmark practical and gives us a consistent reference. We then create a 4× bicubic downsample, optionally encode it as JPEG at quality 70 or 40, decode it, and run the production upscaler back to 4×.&lt;/p&gt;

&lt;p&gt;The clean and JPEG paths let us separate two different questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can the model enlarge clean line art and flat color without inventing damage?&lt;/li&gt;
&lt;li&gt;Does it treat compression artifacts as texture that should be amplified?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;PSNR and SSIM compare the result with the resized source. They do not prove that an image is pleasant to look at. A model can score well and still break a line in a place a viewer notices immediately. That is why every case also has fixed crops and a human review state: pass, warn, fail, or unreviewed.&lt;/p&gt;

&lt;p&gt;The 2× path is evaluated separately. ClearPix currently produces 2× by running 4× super-resolution and downsampling the result; a fast path that threw away that extra work has already failed our quality gate. We are not going to hide that trade-off in an anime-specific report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure checklist
&lt;/h2&gt;

&lt;p&gt;Our review is organized around the things anime viewers actually notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;broken, thickened, or doubled line art;&lt;/li&gt;
&lt;li&gt;bright or dark halos around clean edges;&lt;/li&gt;
&lt;li&gt;synthetic texture in flat colors;&lt;/li&gt;
&lt;li&gt;screentone that disappears, sticks together, or becomes a different pattern;&lt;/li&gt;
&lt;li&gt;banding in gradients;&lt;/li&gt;
&lt;li&gt;JPEG blocks and mosquito noise made more visible;&lt;/li&gt;
&lt;li&gt;shifts in skin, hair, or highlight color;&lt;/li&gt;
&lt;li&gt;transparent pixels turning opaque, or dark and light fringes around alpha edges.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During implementation we confirmed that the shared upscaler was writing alpha 255 for every output pixel. An anime model cannot fix that for us. Alpha now has to be reconstructed from the source at the output size and checked separately from RGB quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why publish the set before claiming a model win
&lt;/h2&gt;

&lt;p&gt;Our production model is &lt;code&gt;realesr-general-x4v3&lt;/code&gt;, a compact general-purpose model. The Real-ESRGAN project also distributes an anime-specific model. The existence of that model is not evidence that we need it, and the word “anime” in a filename is not evidence that it will be better in a browser.&lt;/p&gt;

&lt;p&gt;The general model gets the first run. If it handles line art, flat color, screentone, transparency, and compressed inputs well enough, we will keep the simpler pipeline. If it fails the core cases, any anime-specific alternative will have to beat it on the same inputs, crops, runtime, and download budget.&lt;/p&gt;

&lt;p&gt;We are publishing the &lt;a href="https://clearpix.org/quality" rel="noopener noreferrer"&gt;Quality data and real output crops&lt;/a&gt; so that decision can be inspected. The companion baseline report records what happened, including the cases that did not pass. The eventual &lt;a href="https://clearpix.org/upscale-anime-image" rel="noopener noreferrer"&gt;anime image upscaler&lt;/a&gt; will use the same production path, not a separate demo pipeline.&lt;/p&gt;

&lt;p&gt;That is the point of building the test in public: the page can describe the evidence, rather than asking the evidence to support the page.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>softwaredevelopment</category>
      <category>testing</category>
    </item>
    <item>
      <title>Automatic Watermark Detection: Cutting False Positives on Complex Backgrounds by Design</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Tue, 20 Oct 2026 15:16:17 +0000</pubDate>
      <link>https://dev.to/sharksevenro/automatic-watermark-detection-cutting-false-positives-on-complex-backgrounds-by-design-757</link>
      <guid>https://dev.to/sharksevenro/automatic-watermark-detection-cutting-false-positives-on-complex-backgrounds-by-design-757</guid>
      <description>&lt;p&gt;Our automatic watermark detector returned 125 text boxes on 24 real user photos, and every one of them was pre-selected. Ten were on a baby's face, a film still, a rock, a towel — never text at all. The other 39 we now drop are real text, but it sits on backgrounds an in-browser inpainter cannot rebuild without hallucinating. So we changed the rule: auto-detect now reports only what the model is genuinely good at — 76 boxes, no subject false positives, tiled watermarks untouched — and hands the rest to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers up front
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text boxes on a 24-photo real-user set&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boxes dropped by the new gate&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;49 (10 by shape, 39 by background)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subject false positives we could verify by eye&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiled-watermark boxes kept (case-1 / case-2)&lt;/td&gt;
&lt;td&gt;38 / 22&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36 / 19&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site sample ("SAMPLE ONLY")&lt;/td&gt;
&lt;td&gt;detected&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;still detected&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality bench (13 synthetic cases)&lt;/td&gt;
&lt;td&gt;recall 89.2 %, precision 65.3 %, psnrRegion 24.8 dB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;bit-identical&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing in that table required a new model, a bigger model, or a server. It required deciding which detections we are willing to act on automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually goes wrong: the detector fires on subjects
&lt;/h2&gt;

&lt;p&gt;Our text detector is PP-OCRv5's DB head — the same 4.7 MB model described in &lt;a href="https://clearpix.org/blog/automatic-watermark-detection-yolo-ocr-browser" rel="noopener noreferrer"&gt;part 6&lt;/a&gt;. It is a &lt;em&gt;shape&lt;/em&gt; detector. It answers "where could text be", not "is this a watermark", and it fires on any structure that looks like strokes: high-contrast, roughly periodic, thin.&lt;/p&gt;

&lt;p&gt;Natural photographs are full of such structures. Eyebrows, eyes and lips. Fingers and knuckles. The crumb structure of a dessert. Fabric folds and hair. Window mullions, balcony railings, a crowd. None of them are text, and none of them look like text to a shape detector.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that these false positives are &lt;strong&gt;not weak&lt;/strong&gt;. The boxes sitting on a baby's face and on a film still scored 0.81–0.94 mean detection probability — squarely inside the range of real watermarks on the same set (0.74–0.995). Every confidence gate we already had (mean score ≥ 0.6, minimum area, fill ratio, area cap) was blind to this, because the detector was not confused. It was confidently reporting a face.&lt;/p&gt;

&lt;p&gt;That is the general lesson: "how confident" and "should we act" are different questions. No confidence threshold can separate "real watermark" from "real text-like texture", because the second one scores just as high.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second half of the problem: the inpainter cannot rebuild texture
&lt;/h2&gt;

&lt;p&gt;Detection is the ceiling on removal, but only one half of it. Our inpainting is MI-GAN by default and LaMa as an opt-in quality tier, both running at 512² tiles on the user's own GPU (or WASM on the CPU). That architecture reconstructs a hole from its surrounding context. On a homogeneous background it matches the statistics and the result is invisible. On a busy background it produces texture that is statistically plausible and spatially wrong.&lt;/p&gt;

&lt;p&gt;We have measured both ends of this. On a city-texture case from our synthetic bench, leaving a faint watermark alone scored &lt;strong&gt;MAE 3.11&lt;/strong&gt; against the clean original; repainting the rectangle with the inpainter scored &lt;strong&gt;24.79&lt;/strong&gt; — eight times &lt;em&gt;worse&lt;/em&gt; than doing nothing. On real user photos the same effect shows up as a car-shaped hallucination on sand, a horizon-shaped smear band across a cloud layer, and a rock-like blob where a ridgeline used to be.&lt;/p&gt;

&lt;p&gt;So the two failure modes compound. A false-positive box on a complex background is the worst possible combination: the model is asked to invent texture, and it invents it over the user's actual subject. A false-positive box on a plain wall is merely ugly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we could not just make the model stronger
&lt;/h2&gt;

&lt;p&gt;The usual fix for a noisy detector is a better detector. That was not available to us, and the constraints are worth stating plainly, because they are the reason this post is about &lt;em&gt;product rules&lt;/em&gt; rather than about model work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No server-side fallback.&lt;/strong&gt; The product's promise is that your photo never leaves the tab. We cannot send a hard case to a bigger model and get an answer back; that would be an upload, and it is the one thing we refuse to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No elasticity.&lt;/strong&gt; A hosted detector can be re-trained, re-scaled or replaced with a larger model when a bad case appears. A local model in the user's browser is whatever we shipped: a 4.7 MB OCR model, an 11 MB YOLO model, and a 102.8 MiB fp16 LaMa tier behind an opt-in. Making the detector meaningfully better means new weights, new hosting, and another download for every user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The device is the budget.&lt;/strong&gt; Phones and low-end laptops run the same 512² tile pipeline; the model's receptive field is fixed by its architecture, not by the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We have no training set for "watermark vs. scene text".&lt;/strong&gt; Separating an overlay from a printed label on a product needs semantics and supervised data we do not have, and we cannot collect it from user photos without asking people to upload their images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Given those constraints, the only lever left is &lt;strong&gt;deciding which detections we act on automatically&lt;/strong&gt;. If the model cannot tell an overlay from a face, we can at least tell a &lt;em&gt;simple background&lt;/em&gt; from a &lt;em&gt;busy one&lt;/em&gt;, and we can tell a &lt;em&gt;text line&lt;/em&gt; from a &lt;em&gt;blob&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: play to the model's strengths, hand the rest to the user
&lt;/h2&gt;

&lt;p&gt;The rule we shipped is deliberately conservative: &lt;strong&gt;auto-detect only reports obvious text on a locally simple background.&lt;/strong&gt; Everything else is the user's job — and the user's path for that is not worse than before. Drawing a box runs the same region-constrained detector inside the selection plus shape refinement, so a user who wants a watermark on a busy background can still get it, with the mask snapped to the actual strokes.&lt;/p&gt;

&lt;p&gt;This is a change of philosophy, and we want to be explicit about it. Our detection pipeline had been running on "a miss is recoverable, a false positive is not" (the rule we stated in &lt;a href="https://clearpix.org/blog/human-review-caught-mask-bugs" rel="noopener noreferrer"&gt;part 12&lt;/a&gt;): if we miss a faint watermark, the user brushes over it and the result is fine; if we eat their photo, there is no undo for trust. This change tightens that into: &lt;strong&gt;in auto mode, zero false positives first; a miss is handed to the user.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, in two criteria plus one exemption
&lt;/h2&gt;

&lt;p&gt;The gate runs after the existing box-level gates (score, area, fill) and before shapes are assembled. It applies to the image tool's auto-detect path only — the quality bench, the video pipeline and the manual refine path are untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Criterion 1 — the box must look like a text line.&lt;/strong&gt; A text box must be elongated: aspect ratio ≥ 1.5 (horizontal) or ≤ 0.4 (vertical). Small boxes (short side &amp;lt; 40 px) are exempt, because single characters in a tiled watermark are naturally squarish and tiny. Near-square large boxes are the classic OCR-on-a-subject shape, and this is what killed the baby face (111×85), the film-still boxes (66×54, 133×107, 197×109), the plate of food (46×100) and the cosplay portrait (277×344).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Criterion 2 — the background around the box must be simple.&lt;/strong&gt; We measure a ring band around each box (pad = 35 % of the short side, clamped to 8–64 px) and compute two statistics over it: the standard deviation of luminance, and the mean absolute gradient. A box is accepted if the ring's std ≤ 35 and gradient ≤ 14.&lt;/p&gt;

&lt;p&gt;Two details make that measurement honest rather than decorative. First, &lt;strong&gt;only ring pixels count&lt;/strong&gt; — the pixels inside the box are the watermark's own strokes, and letting them into the statistic would make every watermark look like it sits on a busy background. Second, &lt;strong&gt;the gradient is only taken between ring pixels&lt;/strong&gt;; a stroke edge is not background complexity.&lt;/p&gt;

&lt;p&gt;Calibration came from the 24-photo set. Where a real watermark sat on a plain wall, water or sky, the ring measured std 7–30 and gradient 3–10. Where a false positive sat on a face, an arm, food or a crowd, it measured std 32–89 and gradient 4–27. The thresholds sit in the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exemption — repetition is evidence.&lt;/strong&gt; A group of four or more text boxes with matching sizes (within ±30 % in width and height) is treated as a tiled watermark, and tiled watermarks are exempt from both criteria. Repetition is itself strong evidence that you are looking at an overlay rather than picture content, and tiled watermarks are small per instance, so a single instance is a local hole the inpainter can genuinely fill — on a mountainside if it has to. Our two tiled cases kept 36 of 38 and 19 of 22 boxes; what was dropped there is the low-confidence edge fragments plus, in one case, the single large central title that sits on the ridge.&lt;/p&gt;

&lt;p&gt;One footnote on method, because we had been burned by it before. An earlier round concluded that "ring complexity has no discriminative power" — that result was about a &lt;em&gt;different&lt;/em&gt; question (should this region use a rectangle mask or a glyph mask?). The label space matters: the same measurement separates nothing when it picks a mask strategy, and separates well when it picks whether to auto-select a detection. We have written that distinction into our tuning notes so the two results are not quoted as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs and what it buys
&lt;/h2&gt;

&lt;p&gt;Eliminated, verified by eye on the output overlays: a baby's face and a pair of legs in a family photo, three boxes on a film still, a rock formation in a mountain photo, a towel and a pillow in two selfies, a face-shaped box on a cosplay portrait, a plate of food.&lt;/p&gt;

&lt;p&gt;Left to the user, deliberately: watermarks that sit on busy backgrounds — a large "Luma AI" title over a sunset ridge, a property agent's phone number across a fridge, "LIONSGATE" over clouds, a hand-written title over a cloud bank, a tattoo artist's name along a hand, a wordmark across a model's hair — plus in-screen text that is caption rather than overlay (meme captions, shop signage, a social-app username bar). Those detections are real, and we are choosing not to auto-erase them, because on that background the artifact is the inpainter's output, not the removal. A user who wants them can draw one box and get the same glyph-level mask.&lt;/p&gt;

&lt;p&gt;The rest of the pipeline did not move. The synthetic bench — 13 cases, on average recall 89.2 %, precision 65.3 %, region PSNR 24.8 dB — is bit-identical, because the gate is off in the bench path. (Two bench gates were already failing before this change: one case at 12.3 dB region PSNR and the faint tiled case at 47.9 % recall. Those are pre-existing and unrelated.) The manual refine path is unchanged, and the gate is a single option on the detector call, so it can be turned on or off per surface.&lt;/p&gt;

&lt;p&gt;We did turn it on for the &lt;strong&gt;video&lt;/strong&gt; watermark remover, where a false positive is worse than in a still image: the mask is applied to the whole clip, so a hallucinated patch is replayed frame after frame. The subtitle remover keeps full auto-detection on purpose — a subtitle is the thing the user asked to delete, it can sit on a face or a street scene, and filtering by background would defeat the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The detector described here runs in the &lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;free in-browser watermark remover&lt;/a&gt;, together with the glyph-level masking from &lt;a href="https://clearpix.org/blog/automatic-watermark-detection-yolo-ocr-browser" rel="noopener noreferrer"&gt;part 6&lt;/a&gt; and the mask-discipline fixes from &lt;a href="https://clearpix.org/blog/human-review-caught-mask-bugs" rel="noopener noreferrer"&gt;part 12&lt;/a&gt;. Nothing is uploaded: detection, mask building and inpainting all happen in the tab. If you want the other half of the pipeline — &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;upscale a photo 4× without uploading it&lt;/a&gt; — it runs on the same local models.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 15 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>design</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>FP16 Quantization Quality Loss: We Blamed the Architecture, the Bug Was One Overflowed Tensor</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:23:18 +0000</pubDate>
      <link>https://dev.to/sharksevenro/fp16-quantization-quality-loss-we-blamed-the-architecture-the-bug-was-one-overflowed-tensor-5e47</link>
      <guid>https://dev.to/sharksevenro/fp16-quantization-quality-loss-we-blamed-the-architecture-the-bug-was-one-overflowed-tensor-5e47</guid>
      <description>&lt;p&gt;Our fp16 quantization post-mortem was wrong in the expensive direction: it told anyone with a mask-gated model to give up on half-precision, and that verdict would have kept a 52%-smaller download off the internet. The real cause was sixteen BatchNorm variance tensors larger than fp16's maximum representable value, silently clamped by the converter, leaving sixteen layers normalizing their output by the wrong divisor. The fix is two lines of algebra. The reward is a model that matches fp32 and downloads 52% smaller.&lt;/p&gt;

&lt;p&gt;This is a correction post, so it comes with receipts: the lab notes for this round are checked in next to the code, every figure below is reproducible from them on a CPU-only ONNX Runtime install, and the whole investigation fits in a day or two of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim we published
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://clearpix.org/blog/fp16-onnx-webgpu-browser-inference" rel="noopener noreferrer"&gt;Part 3 of this series&lt;/a&gt; ends with a case study called "When fp16 quantization quality loss is real: the LaMa failure". The measurements in it are real, and the failure is reproducible: rebuilding that conversion from the same weights today, the unfixed fp16 model averages 15.8 dB against fp32's 29.7 dB across our 14 acceptance cases, and its worst case scores 11.1 dB — the watermark surviving, exactly as described. &lt;a href="https://clearpix.org/blog/running-lama-realesrgan-in-the-browser" rel="noopener noreferrer"&gt;Part 5&lt;/a&gt; repeats it as "its catastrophic mirror image when we tried it on LaMa". &lt;a href="https://clearpix.org/blog/private-in-browser-watermark-removal-architecture" rel="noopener noreferrer"&gt;Part 1's series index&lt;/a&gt; summarises the lesson as "mask-gated architectures and precision sensitivity".&lt;/p&gt;

&lt;p&gt;We offered a mechanism too, and it was a good story. LaMa is mask-gated: the mask doesn't just mark where to work, it gates the network's behavior. A mask-gated inpainter therefore has a decision boundary inside it — which regions are trustworthy context, which must be regenerated — and half-precision noise pushes the model across that boundary. The gating fails softly, the model starts copying the watermarked pixels through instead of repairing them, and the output looks plausible except the watermark is still there.&lt;/p&gt;

&lt;p&gt;That story explained the symptom exactly. It was consistent with everything else we'd learned (browser inference is bandwidth-bound, so moving half the bytes is free money). And it produced a tidy rule that we then repeated to readers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;whether quantization is safe is a property of the model's architecture, not of your tooling&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule is wrong. Not "wrong with exceptions" — wrong as a predictor, and expensive: it would have kept a quality-identical model that is 48% smaller out of the product forever, and it tells every reader with an attention head, a gating mask, or a long residual chain to stop investigating. &lt;a href="https://clearpix.org/blog/watermark-removal-artifacts-blend-not-model" rel="noopener noreferrer"&gt;Part 8 of this series&lt;/a&gt; was about a quality bug that turned out to be ours, not the model's. This is the same shape of mistake, one layer lower in the stack: our conversion tooling, not the model's architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: prove that precision is not even the variable
&lt;/h2&gt;

&lt;p&gt;"FP16" bundles two independent changes: &lt;strong&gt;mantissa precision&lt;/strong&gt; (how finely you can split a value) and &lt;strong&gt;numeric range&lt;/strong&gt; (the largest magnitude you can hold at all). If the failure were precision sensitivity, then rounding the mantissa while keeping fp32's range would degrade quality in proportion. So we built models that keep the graph and all arithmetic in fp32, and only round the weights' mantissas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weights rounded to&lt;/th&gt;
&lt;th&gt;Region PSNR (5 cases)&lt;/th&gt;
&lt;th&gt;Output fidelity vs fp32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32 (baseline)&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7-bit mantissa (bf16)&lt;/td&gt;
&lt;td&gt;28.83&lt;/td&gt;
&lt;td&gt;66.9–76.9 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10-bit mantissa (= fp16)&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;74.5–96.6 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13-bit mantissa&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;95.9–109.0 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;fp16 has a 10-bit mantissa. The 10-bit row is &lt;strong&gt;0.004 dB&lt;/strong&gt; off the fp32 baseline, and even throwing away 16 of the 23 mantissa bits (7-bit, coarser than bf16) costs 0.054 dB. LaMa is not precision-sensitive — it is precision-indifferent. Whatever fp16 was doing to it, it wasn't the mantissa.&lt;/p&gt;

&lt;p&gt;That left range, and range has a hard, checkable boundary: fp16's largest finite value is 65,504. So we dumped every fp32 initializer in the graph and sorted by magnitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: find the numbers that don't fit
&lt;/h2&gt;

&lt;p&gt;The graph is 17,480 nodes with 606 float32 initializer tensors, 51M parameters, 204 MB of weights — 197 MB of it in the FFT path (17,022 of those nodes are Fourier convolutions, 97%). Run the check below over the 606 tensors and sixteen come back &lt;strong&gt;above 65,504&lt;/strong&gt;, and every single one is a BatchNorm &lt;code&gt;running_var&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     484322.56  model.generator.model.5.conv1.bn_l.running_var
     440853.66  model.generator.model.25.running_var
     240497.69  model.generator.model.22.conv1.bn_l.running_var
     218544.31  model.generator.model.11.conv1.bn_l.running_var
     174809.92  model.generator.model.6.conv1.bn_l.running_var
      65564.05  model.generator.model.10.conv1.bn_l.running_var
      ...  16 of 606 tensors, every one of them a running_var
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last row: the marginal tensor clears the ceiling by 0.09%, and it gets clamped to exactly the same &lt;code&gt;65,504&lt;/code&gt; as the one that clears it by 7×. That's the whole hazard — a clamped value sits exactly at fp16's ceiling, which is a value fp16 can barely hold, so the next fp16 pass over that tensor takes it to infinity.&lt;/p&gt;

&lt;p&gt;This is a five-line check, and it is the first thing to run before believing any "this model can't be quantized" conclusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy_helper&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lama_fp32.onnx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_external_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializer&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# 1 = float32
&lt;/span&gt;    &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;14.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen tensors. Not sixteen layers of masking logic — sixteen numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a BatchNorm variance is the thing that overflows
&lt;/h2&gt;

&lt;p&gt;A BatchNorm layer computes &lt;code&gt;y = (x − μ) / sqrt(var + ε) · γ + β&lt;/code&gt;. &lt;code&gt;running_var&lt;/code&gt; is a &lt;em&gt;scale&lt;/em&gt; quantity measured over the layer's input during training. If a layer's inputs live at large magnitudes, its variance lives at the square of those magnitudes, and &lt;code&gt;484,322&lt;/code&gt; is entirely legitimate in fp32: &lt;code&gt;sqrt(484322) ≈ 696&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Clamp that variance to fp16's ceiling of 65,504 and the divisor becomes &lt;code&gt;sqrt(65504) ≈ 256&lt;/code&gt;. The layer now normalizes by 256 instead of 696 — its output is &lt;strong&gt;2.7× too large&lt;/strong&gt; — and the convolution that consumes it was trained against the correct scale. Sixteen of those, in series, and the network's internal magnitudes are destroyed while the graph still runs and still produces plausible-looking images.&lt;/p&gt;

&lt;p&gt;That last part is the trap. There is no NaN, no warning, no crash: just a model that quietly keeps the watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp that never said a word
&lt;/h2&gt;

&lt;p&gt;The conversion never told us anything, and this is the part worth internalising. &lt;code&gt;onnxruntime.transformers.float16.convert_float_to_float16&lt;/code&gt; clamps to &lt;code&gt;max_finite_val=65504&lt;/code&gt;; &lt;code&gt;onnxconverter_common.float16&lt;/code&gt; defaults to &lt;code&gt;10000&lt;/code&gt; (48× more aggressive). Both clamp silently — that's what "clamp" means: the fp16 model has a finite, wrong number in it, and nothing anywhere prints a line.&lt;/p&gt;

&lt;p&gt;You can watch the corruption happen tensor by tensor, which is how we finally believed it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;&lt;code&gt;model.5.conv1.bn_l.running_var&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32 source&lt;/td&gt;
&lt;td&gt;484322.5625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after &lt;code&gt;keep_io_types&lt;/code&gt; fp16 conversion&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;65504.0&lt;/strong&gt; (clamped to the fp16 ceiling)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after a further fp16 storage pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;inf&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after our fix (see below)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1892.0&lt;/strong&gt; (fits comfortably in fp16)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the third row: the clamped value is already at the ceiling, so a second conversion overflows it to infinity. Whether your broken model lands on &lt;code&gt;65504&lt;/code&gt; or &lt;code&gt;inf&lt;/code&gt; depends on which converter pass touched it last — which is also why the failure looked so erratic in our old notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is algebra
&lt;/h2&gt;

&lt;p&gt;We tried the blunt version first: keep the sixteen offending BatchNorm nodes in fp32 and convert everything else. That works, costs 34 KB of fp32 parameters, and produced identical quality — but it leaves a graph with fp32 islands and sixteen permanent assertions about which layers are dangerous.&lt;/p&gt;

&lt;p&gt;The better fix is to notice that a BatchNorm's variance and its scale are only meaningful &lt;em&gt;relative to each other&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;y = (x − μ) / sqrt(var + ε) · γ + β
  ≡ (x − μ) / sqrt(var/K² + ε/K²) · (γ/K) + β
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So pick a power-of-two &lt;code&gt;K&lt;/code&gt; per layer, divide the variance by &lt;code&gt;K²&lt;/code&gt;, divide the scale by &lt;code&gt;K&lt;/code&gt;, and divide &lt;code&gt;epsilon&lt;/code&gt; by &lt;code&gt;K²&lt;/code&gt; — the layer is mathematically identical, and now &lt;code&gt;var/K²&lt;/code&gt; fits in fp16. With &lt;code&gt;K = 2^ceil(log2(max|var| / 40000))&lt;/code&gt;, that's a twenty-line script touching 19 of the 75 BatchNorm layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_abs_var&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;40000.0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;   &lt;span class="c1"&gt;# K = 16 for the worst layer
&lt;/span&gt;&lt;span class="n"&gt;var_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_array&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;var_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;scale_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_array&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;scale_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# and the epsilon attribute: a.f = a.f / (k * k)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is worth stating precisely, because it kills the "mixed precision is inherently fuzzy" intuition: the converted model has &lt;strong&gt;606 fp16 initializers and zero fp32 residue&lt;/strong&gt;. It is 100% fp16, every tensor, with no casts added around the BatchNorms. And the arithmetic is exact — powers of two, so nothing is lost to rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is worth — and what it is not
&lt;/h2&gt;

&lt;p&gt;Three independent measurements on the same converted weights, plus a control and a harness sanity check:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;fp32&lt;/th&gt;
&lt;th&gt;fp16 (fixed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14-case masked-region PSNR&lt;/td&gt;
&lt;td&gt;29.74 dB&lt;/td&gt;
&lt;td&gt;29.73 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-case ghost-score drift (mean / max)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0014 / 0.005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser runtime (ORT Web WASM), same tile, vs fp32 reference&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;66.5 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser runtime (ORT Web WebGPU), same tile, vs WASM reference&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;71.6 dB, at 0.86 s vs 4.06 s per tile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unfixed fp16, same test (control)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;22.6 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 real photo regions, production-detected boxes&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;59.9 dB mean, 40.3 dB min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The browser row is the one that matters for shipping: that's the exact runtime our production HQ tier uses, and 66.5 dB means the two models disagree at the level of sub-grey-step rounding. The control row is what the old mechanism &lt;em&gt;should&lt;/em&gt; have looked like if it were real precision damage — 22.6 dB, i.e. visibly different. (Sanity check on the harness: browser fp32 vs a CPU fp32 reference on the same tile is 156 dB, so the comparison chain isn't the limiting factor.)&lt;/p&gt;

&lt;p&gt;The size win is the point of the exercise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;brotli q11 (what users download)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32&lt;/td&gt;
&lt;td&gt;198.4 MiB&lt;/td&gt;
&lt;td&gt;179.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp16 (fixed)&lt;/td&gt;
&lt;td&gt;102.8 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.3 MiB (−52%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The brotli pipeline that makes a 198 MiB weight file a 179 MiB download in the first place is &lt;a href="https://clearpix.org/blog/brotli-wasm-models-r2-delivery" rel="noopener noreferrer"&gt;part 9's story&lt;/a&gt; — worth saying here because the win compounds: same compressor, half the bytes.&lt;/p&gt;

&lt;p&gt;And now the honest part, which is the real lesson for anyone shipping fp16 in a browser: &lt;strong&gt;on the WASM execution provider, fp16 made our inpainting slower, not faster.&lt;/strong&gt; 11.4 s → 17.4 s per tile, 2.7 s → 9.4 s on CPU. The WASM EP has no native fp16 convolution kernels, so it inserts casts and computes in fp32 anyway, paying the copy tax for nothing. Part 3's 3.5× speedup came from WebGPU, which has real fp16 ALUs; on a WASM-only path, half-precision buys bytes and nothing else.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-10-18):&lt;/strong&gt; the WASM-only path was never a permanent constraint, and the reason we thought it was had nothing to do with quantization. The "JSEP gap" keeping HQ off WebGPU was a one-element off-by-one in the default &lt;code&gt;onnxruntime-web&lt;/code&gt; build's negative-step &lt;code&gt;Slice&lt;/code&gt;. Fixed by importing the WebGPU build, the same fp16 weights now run at &lt;strong&gt;0.86 s per tile against WASM's 4.06 s&lt;/strong&gt; (4.7×), at 71.6 dB agreement — so on desktop fp16 collects the speed win too, and on WASM it stays a pure download-size trade. See &lt;a href="https://clearpix.org/blog/lama-webgpu-slice-off-by-one" rel="noopener noreferrer"&gt;part 14&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The int8 half, where the conclusion survives and the reason does not
&lt;/h2&gt;

&lt;p&gt;The old post also reported int8 failing (24.5 → 15.6 dB on our dark-text case) and concluded that quantization is architecture-hostile in general. We re-ran both int8 recipes on the current five-case subset (our case definitions have drifted since 2026-10, so compare within a run, not across posts):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Region PSNR&lt;/th&gt;
&lt;th&gt;vs fp32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8 dynamic (weights only, per-channel)&lt;/td&gt;
&lt;td&gt;17.82&lt;/td&gt;
&lt;td&gt;−11.1 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8 QDQ (activations too), &lt;strong&gt;all 75 BatchNorms excluded&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;19.11&lt;/td&gt;
&lt;td&gt;−9.8 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So int8 is still dead for this model — the conclusion stands. But the mechanism is the opposite of the one we published: this isn't a weight-precision problem, and it isn't the BatchNorm overflow either. Explicitly excluding every BatchNorm from quantization doesn't rescue it, because the &lt;em&gt;inputs&lt;/em&gt; to those 75 layers still arrive through &lt;code&gt;DequantizeLinear&lt;/code&gt;. Counting the graph: 432 &lt;code&gt;Einsum&lt;/code&gt;/&lt;code&gt;MatMul&lt;/code&gt; nodes with 648 inputs wrapped in quantize/dequantize pairs. The FFT path is expanded into spectral matrix products during export, and that's the part that cannot survive 8-bit activations. Different failure, different size of blast radius, and it stays on the do-not-retry list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected decision order
&lt;/h2&gt;

&lt;p&gt;Our old order was architecture → bit width → runtime. The new one starts one step earlier, because a conversion artifact can impersonate an architectural verdict:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit the values before you audit the architecture.&lt;/strong&gt; Dump &lt;code&gt;max |value|&lt;/code&gt; for every initializer, compare against the target dtype's ceiling, and check the subnormal floor while you're there. In our model 16 of 606 tensors overflowed fp16 (0.087% of parameters sat below the subnormal floor — harmless, but the same scan finds both). This costs minutes and can invalidate a conclusion you'd otherwise keep for months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate precision from range.&lt;/strong&gt; Round the mantissa, keep the range. If quality doesn't move, precision is not your variable and no amount of "the model is sensitive" theorising will help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then test the structure.&lt;/strong&gt; Gating, long accumulation chains, adversarial spectral paths — all real failure modes, but they're hypotheses to test, not a diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify in the runtime you ship.&lt;/strong&gt; A Python CPU pass proves the weights; it does not prove that ORT Web's WASM EP, with its cast insertion and kernel gaps, produces the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarks decide admission.&lt;/strong&gt; Unchanged, and it's the rule that caught this: the broken model &lt;em&gt;looked&lt;/em&gt; plausible at 15.8 dB, the fixed one measures 66.5 dB against the fp32 reference, and only the benchmark could tell you which was which.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two corrections to our own record, then: the LaMa fp16 entry comes off the do-not-retry list and onto the opt-in path (fp16 verified against fp32 on every check above, fp32 spec retained as the fallback), the int8 entry stays, and the sentence in parts 1 and 5 that makes quantization safety a property of architecture gets retired in favour of something less quotable and more true: &lt;strong&gt;a quantization failure is a measurement, and most measurements have a checkable cause.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything here runs client-side with no uploads. The fp32 model that this post improves powers the &lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;free watermark remover&lt;/a&gt;, and the inpainting engine is the same one behind the &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;video watermark remover&lt;/a&gt;. The fp16 path that started all of this is live in our &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;image upscaler&lt;/a&gt;, and the &lt;a href="https://clearpix.org/quality" rel="noopener noreferrer"&gt;quality dashboard&lt;/a&gt; publishes the bench numbers these gates are built on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 13 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>FP16 Quantization Quality Loss: We Blamed the Architecture, the Bug Was One Overflowed Tensor</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Sat, 17 Oct 2026 01:10:37 +0000</pubDate>
      <link>https://dev.to/sharksevenro/fp16-quantization-quality-loss-we-blamed-the-architecture-the-bug-was-one-overflowed-tensor-3aj9</link>
      <guid>https://dev.to/sharksevenro/fp16-quantization-quality-loss-we-blamed-the-architecture-the-bug-was-one-overflowed-tensor-3aj9</guid>
      <description>&lt;p&gt;Our fp16 quantization post-mortem was wrong in the expensive direction: it told anyone with a mask-gated model to give up on half-precision, and that verdict would have kept a 52%-smaller download off the internet. The real cause was sixteen BatchNorm variance tensors larger than fp16's maximum representable value, silently clamped by the converter, leaving sixteen layers normalizing their output by the wrong divisor. The fix is two lines of algebra. The reward is a model that matches fp32 and downloads 52% smaller.&lt;/p&gt;

&lt;p&gt;This is a correction post, so it comes with receipts: the lab notes for this round are checked in next to the code, every figure below is reproducible from them on a CPU-only ONNX Runtime install, and the whole investigation fits in a day or two of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim we published
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://clearpix.org/blog/fp16-onnx-webgpu-browser-inference" rel="noopener noreferrer"&gt;Part 3 of this series&lt;/a&gt; ends with a case study called "When fp16 quantization quality loss is real: the LaMa failure". The measurements in it are real, and the failure is reproducible: rebuilding that conversion from the same weights today, the unfixed fp16 model averages 15.8 dB against fp32's 29.7 dB across our 14 acceptance cases, and its worst case scores 11.1 dB — the watermark surviving, exactly as described. &lt;a href="https://clearpix.org/blog/running-lama-realesrgan-in-the-browser" rel="noopener noreferrer"&gt;Part 5&lt;/a&gt; repeats it as "its catastrophic mirror image when we tried it on LaMa". &lt;a href="https://clearpix.org/blog/private-in-browser-watermark-removal-architecture" rel="noopener noreferrer"&gt;Part 1's series index&lt;/a&gt; summarises the lesson as "mask-gated architectures and precision sensitivity".&lt;/p&gt;

&lt;p&gt;We offered a mechanism too, and it was a good story. LaMa is mask-gated: the mask doesn't just mark where to work, it gates the network's behavior. A mask-gated inpainter therefore has a decision boundary inside it — which regions are trustworthy context, which must be regenerated — and half-precision noise pushes the model across that boundary. The gating fails softly, the model starts copying the watermarked pixels through instead of repairing them, and the output looks plausible except the watermark is still there.&lt;/p&gt;

&lt;p&gt;That story explained the symptom exactly. It was consistent with everything else we'd learned (browser inference is bandwidth-bound, so moving half the bytes is free money). And it produced a tidy rule that we then repeated to readers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;whether quantization is safe is a property of the model's architecture, not of your tooling&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule is wrong. Not "wrong with exceptions" — wrong as a predictor, and expensive: it would have kept a quality-identical model that is 48% smaller out of the product forever, and it tells every reader with an attention head, a gating mask, or a long residual chain to stop investigating. &lt;a href="https://clearpix.org/blog/watermark-removal-artifacts-blend-not-model" rel="noopener noreferrer"&gt;Part 8 of this series&lt;/a&gt; was about a quality bug that turned out to be ours, not the model's. This is the same shape of mistake, one layer lower in the stack: our conversion tooling, not the model's architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: prove that precision is not even the variable
&lt;/h2&gt;

&lt;p&gt;"FP16" bundles two independent changes: &lt;strong&gt;mantissa precision&lt;/strong&gt; (how finely you can split a value) and &lt;strong&gt;numeric range&lt;/strong&gt; (the largest magnitude you can hold at all). If the failure were precision sensitivity, then rounding the mantissa while keeping fp32's range would degrade quality in proportion. So we built models that keep the graph and all arithmetic in fp32, and only round the weights' mantissas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weights rounded to&lt;/th&gt;
&lt;th&gt;Region PSNR (5 cases)&lt;/th&gt;
&lt;th&gt;Output fidelity vs fp32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32 (baseline)&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7-bit mantissa (bf16)&lt;/td&gt;
&lt;td&gt;28.83&lt;/td&gt;
&lt;td&gt;66.9–76.9 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10-bit mantissa (= fp16)&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;74.5–96.6 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13-bit mantissa&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;95.9–109.0 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;fp16 has a 10-bit mantissa. The 10-bit row is &lt;strong&gt;0.004 dB&lt;/strong&gt; off the fp32 baseline, and even throwing away 16 of the 23 mantissa bits (7-bit, coarser than bf16) costs 0.054 dB. LaMa is not precision-sensitive — it is precision-indifferent. Whatever fp16 was doing to it, it wasn't the mantissa.&lt;/p&gt;

&lt;p&gt;That left range, and range has a hard, checkable boundary: fp16's largest finite value is 65,504. So we dumped every fp32 initializer in the graph and sorted by magnitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: find the numbers that don't fit
&lt;/h2&gt;

&lt;p&gt;The graph is 17,480 nodes with 606 float32 initializer tensors, 51M parameters, 204 MB of weights — 197 MB of it in the FFT path (17,022 of those nodes are Fourier convolutions, 97%). Run the check below over the 606 tensors and sixteen come back &lt;strong&gt;above 65,504&lt;/strong&gt;, and every single one is a BatchNorm &lt;code&gt;running_var&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     484322.56  model.generator.model.5.conv1.bn_l.running_var
     440853.66  model.generator.model.25.running_var
     240497.69  model.generator.model.22.conv1.bn_l.running_var
     218544.31  model.generator.model.11.conv1.bn_l.running_var
     174809.92  model.generator.model.6.conv1.bn_l.running_var
      65564.05  model.generator.model.10.conv1.bn_l.running_var
      ...  16 of 606 tensors, every one of them a running_var
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last row: the marginal tensor clears the ceiling by 0.09%, and it gets clamped to exactly the same &lt;code&gt;65,504&lt;/code&gt; as the one that clears it by 7×. That's the whole hazard — a clamped value sits exactly at fp16's ceiling, which is a value fp16 can barely hold, so the next fp16 pass over that tensor takes it to infinity.&lt;/p&gt;

&lt;p&gt;This is a five-line check, and it is the first thing to run before believing any "this model can't be quantized" conclusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy_helper&lt;/span&gt;

&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onnx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lama_fp32.onnx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load_external_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initializer&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# 1 = float32
&lt;/span&gt;    &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;14.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen tensors. Not sixteen layers of masking logic — sixteen numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a BatchNorm variance is the thing that overflows
&lt;/h2&gt;

&lt;p&gt;A BatchNorm layer computes &lt;code&gt;y = (x − μ) / sqrt(var + ε) · γ + β&lt;/code&gt;. &lt;code&gt;running_var&lt;/code&gt; is a &lt;em&gt;scale&lt;/em&gt; quantity measured over the layer's input during training. If a layer's inputs live at large magnitudes, its variance lives at the square of those magnitudes, and &lt;code&gt;484,322&lt;/code&gt; is entirely legitimate in fp32: &lt;code&gt;sqrt(484322) ≈ 696&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Clamp that variance to fp16's ceiling of 65,504 and the divisor becomes &lt;code&gt;sqrt(65504) ≈ 256&lt;/code&gt;. The layer now normalizes by 256 instead of 696 — its output is &lt;strong&gt;2.7× too large&lt;/strong&gt; — and the convolution that consumes it was trained against the correct scale. Sixteen of those, in series, and the network's internal magnitudes are destroyed while the graph still runs and still produces plausible-looking images.&lt;/p&gt;

&lt;p&gt;That last part is the trap. There is no NaN, no warning, no crash: just a model that quietly keeps the watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp that never said a word
&lt;/h2&gt;

&lt;p&gt;The conversion never told us anything, and this is the part worth internalising. &lt;code&gt;onnxruntime.transformers.float16.convert_float_to_float16&lt;/code&gt; clamps to &lt;code&gt;max_finite_val=65504&lt;/code&gt;; &lt;code&gt;onnxconverter_common.float16&lt;/code&gt; defaults to &lt;code&gt;10000&lt;/code&gt; (48× more aggressive). Both clamp silently — that's what "clamp" means: the fp16 model has a finite, wrong number in it, and nothing anywhere prints a line.&lt;/p&gt;

&lt;p&gt;You can watch the corruption happen tensor by tensor, which is how we finally believed it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;&lt;code&gt;model.5.conv1.bn_l.running_var&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32 source&lt;/td&gt;
&lt;td&gt;484322.5625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after &lt;code&gt;keep_io_types&lt;/code&gt; fp16 conversion&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;65504.0&lt;/strong&gt; (clamped to the fp16 ceiling)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after a further fp16 storage pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;inf&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after our fix (see below)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1892.0&lt;/strong&gt; (fits comfortably in fp16)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the third row: the clamped value is already at the ceiling, so a second conversion overflows it to infinity. Whether your broken model lands on &lt;code&gt;65504&lt;/code&gt; or &lt;code&gt;inf&lt;/code&gt; depends on which converter pass touched it last — which is also why the failure looked so erratic in our old notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is algebra
&lt;/h2&gt;

&lt;p&gt;We tried the blunt version first: keep the sixteen offending BatchNorm nodes in fp32 and convert everything else. That works, costs 34 KB of fp32 parameters, and produced identical quality — but it leaves a graph with fp32 islands and sixteen permanent assertions about which layers are dangerous.&lt;/p&gt;

&lt;p&gt;The better fix is to notice that a BatchNorm's variance and its scale are only meaningful &lt;em&gt;relative to each other&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;y = (x − μ) / sqrt(var + ε) · γ + β
  ≡ (x − μ) / sqrt(var/K² + ε/K²) · (γ/K) + β
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So pick a power-of-two &lt;code&gt;K&lt;/code&gt; per layer, divide the variance by &lt;code&gt;K²&lt;/code&gt;, divide the scale by &lt;code&gt;K&lt;/code&gt;, and divide &lt;code&gt;epsilon&lt;/code&gt; by &lt;code&gt;K²&lt;/code&gt; — the layer is mathematically identical, and now &lt;code&gt;var/K²&lt;/code&gt; fits in fp16. With &lt;code&gt;K = 2^ceil(log2(max|var| / 40000))&lt;/code&gt;, that's a twenty-line script touching 19 of the 75 BatchNorm layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_abs_var&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;40000.0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;   &lt;span class="c1"&gt;# K = 16 for the worst layer
&lt;/span&gt;&lt;span class="n"&gt;var_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_array&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;var_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;scale_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CopyFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numpy_helper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_array&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;scale_init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# and the epsilon attribute: a.f = a.f / (k * k)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is worth stating precisely, because it kills the "mixed precision is inherently fuzzy" intuition: the converted model has &lt;strong&gt;606 fp16 initializers and zero fp32 residue&lt;/strong&gt;. It is 100% fp16, every tensor, with no casts added around the BatchNorms. And the arithmetic is exact — powers of two, so nothing is lost to rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is worth — and what it is not
&lt;/h2&gt;

&lt;p&gt;Three independent measurements on the same converted weights, plus a control and a harness sanity check:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;fp32&lt;/th&gt;
&lt;th&gt;fp16 (fixed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14-case masked-region PSNR&lt;/td&gt;
&lt;td&gt;29.74 dB&lt;/td&gt;
&lt;td&gt;29.73 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-case ghost-score drift (mean / max)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0014 / 0.005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser runtime (ORT Web WASM), same tile, vs fp32 reference&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;66.5 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unfixed fp16, same test (control)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;22.6 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 real photo regions, production-detected boxes&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;59.9 dB mean, 40.3 dB min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The browser row is the one that matters for shipping: that's the exact runtime our production HQ tier uses, and 66.5 dB means the two models disagree at the level of sub-grey-step rounding. The control row is what the old mechanism &lt;em&gt;should&lt;/em&gt; have looked like if it were real precision damage — 22.6 dB, i.e. visibly different. (Sanity check on the harness: browser fp32 vs a CPU fp32 reference on the same tile is 156 dB, so the comparison chain isn't the limiting factor.)&lt;/p&gt;

&lt;p&gt;The size win is the point of the exercise:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;brotli q11 (what users download)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32&lt;/td&gt;
&lt;td&gt;198.4 MiB&lt;/td&gt;
&lt;td&gt;179.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fp16 (fixed)&lt;/td&gt;
&lt;td&gt;102.8 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.3 MiB (−52%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The brotli pipeline that makes a 198 MiB weight file a 179 MiB download in the first place is &lt;a href="https://clearpix.org/blog/brotli-wasm-models-r2-delivery" rel="noopener noreferrer"&gt;part 9's story&lt;/a&gt; — worth saying here because the win compounds: same compressor, half the bytes.&lt;/p&gt;

&lt;p&gt;And now the honest part, which is the real lesson for anyone shipping fp16 in a browser: &lt;strong&gt;fp16 made our inpainting slower, not faster.&lt;/strong&gt; 11.4 s → 17.4 s per tile on the WASM execution provider, 2.7 s → 9.4 s on CPU. The WASM EP has no native fp16 convolution kernels, so it inserts casts and computes in fp32 anyway, paying the copy tax for nothing. Part 3's 3.5× speedup came from WebGPU, which has real fp16 ALUs; on the WebGPU-disabled HQ path, half-precision buys bytes and nothing else. If a future ORT Web release ships fp16 kernels on WASM — or the JSEP gap that keeps HQ off WebGPU gets closed upstream — this model collects the speed win too. The fix is the precondition for that, not the payoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The int8 half, where the conclusion survives and the reason does not
&lt;/h2&gt;

&lt;p&gt;The old post also reported int8 failing (24.5 → 15.6 dB on our dark-text case) and concluded that quantization is architecture-hostile in general. We re-ran both int8 recipes on the current five-case subset (our case definitions have drifted since 2026-10, so compare within a run, not across posts):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Region PSNR&lt;/th&gt;
&lt;th&gt;vs fp32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32&lt;/td&gt;
&lt;td&gt;28.88&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8 dynamic (weights only, per-channel)&lt;/td&gt;
&lt;td&gt;17.82&lt;/td&gt;
&lt;td&gt;−11.1 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;int8 QDQ (activations too), &lt;strong&gt;all 75 BatchNorms excluded&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;19.11&lt;/td&gt;
&lt;td&gt;−9.8 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So int8 is still dead for this model — the conclusion stands. But the mechanism is the opposite of the one we published: this isn't a weight-precision problem, and it isn't the BatchNorm overflow either. Explicitly excluding every BatchNorm from quantization doesn't rescue it, because the &lt;em&gt;inputs&lt;/em&gt; to those 75 layers still arrive through &lt;code&gt;DequantizeLinear&lt;/code&gt;. Counting the graph: 432 &lt;code&gt;Einsum&lt;/code&gt;/&lt;code&gt;MatMul&lt;/code&gt; nodes with 648 inputs wrapped in quantize/dequantize pairs. The FFT path is expanded into spectral matrix products during export, and that's the part that cannot survive 8-bit activations. Different failure, different size of blast radius, and it stays on the do-not-retry list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected decision order
&lt;/h2&gt;

&lt;p&gt;Our old order was architecture → bit width → runtime. The new one starts one step earlier, because a conversion artifact can impersonate an architectural verdict:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit the values before you audit the architecture.&lt;/strong&gt; Dump &lt;code&gt;max |value|&lt;/code&gt; for every initializer, compare against the target dtype's ceiling, and check the subnormal floor while you're there. In our model 16 of 606 tensors overflowed fp16 (0.087% of parameters sat below the subnormal floor — harmless, but the same scan finds both). This costs minutes and can invalidate a conclusion you'd otherwise keep for months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate precision from range.&lt;/strong&gt; Round the mantissa, keep the range. If quality doesn't move, precision is not your variable and no amount of "the model is sensitive" theorising will help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then test the structure.&lt;/strong&gt; Gating, long accumulation chains, adversarial spectral paths — all real failure modes, but they're hypotheses to test, not a diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify in the runtime you ship.&lt;/strong&gt; A Python CPU pass proves the weights; it does not prove that ORT Web's WASM EP, with its cast insertion and kernel gaps, produces the same thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarks decide admission.&lt;/strong&gt; Unchanged, and it's the rule that caught this: the broken model &lt;em&gt;looked&lt;/em&gt; plausible at 15.8 dB, the fixed one measures 66.5 dB against the fp32 reference, and only the benchmark could tell you which was which.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two corrections to our own record, then: the LaMa fp16 entry comes off the do-not-retry list and onto the opt-in path (fp16 verified against fp32 on every check above, fp32 spec retained as the fallback), the int8 entry stays, and the sentence in parts 1 and 5 that makes quantization safety a property of architecture gets retired in favour of something less quotable and more true: &lt;strong&gt;a quantization failure is a measurement, and most measurements have a checkable cause.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything here runs client-side with no uploads. The fp32 model that this post improves powers the &lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;free watermark remover&lt;/a&gt;, and the inpainting engine is the same one behind the &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;video watermark remover&lt;/a&gt;. The fp16 path that started all of this is live in our &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;image upscaler&lt;/a&gt;, and the &lt;a href="https://clearpix.org/quality" rel="noopener noreferrer"&gt;quality dashboard&lt;/a&gt; publishes the bench numbers these gates are built on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 13 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>Upscale Video to 4K Without Uploading It: Up to 7 Faster, Sharper, and Nothing Leaves Your Laptop</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Fri, 16 Oct 2026 17:14:01 +0000</pubDate>
      <link>https://dev.to/sharksevenro/upscale-video-to-4k-without-uploading-it-up-to-7x-faster-sharper-and-nothing-leaves-your-laptop-5495</link>
      <guid>https://dev.to/sharksevenro/upscale-video-to-4k-without-uploading-it-up-to-7x-faster-sharper-and-nothing-leaves-your-laptop-5495</guid>
      <description>&lt;p&gt;Two things were true about our video upscaler when this round started: it was honest about being slow, and we had no idea whether it was any good. The first half is fixable on paper. The second half is why we spent a week building a ruler before touching a single line of the optimizer. What came out the other side: static shots now upscale 4.7–7× faster, talking-head footage — a static background with a moving subject, the case that used to get zero benefit — goes from a flat 1.00× to about 1.8×, and the output is measurably sharper than what we shipped last month. All of it inside a browser tab, with your video never leaving your machine. Not "we delete the file after processing": there is no upload path in the code.&lt;/p&gt;

&lt;p&gt;We build ClearPix, the in-browser media toolbox from parts 1–10 of this series — no uploads, no server GPUs, Real-ESRGAN-class super-resolution running on WebGPU inside your browser. The video pipeline itself (hardware decode, per-frame AI, hardware encode, audio passthrough, O(1) memory) was part 2's story. This round was about turning that pipeline up to 4K and answering the two questions that actually matter for a video tool: how fast, and how good?&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: Build the ruler before you touch the dial
&lt;/h2&gt;

&lt;p&gt;We have a house rule by now, and we earned it the expensive way: &lt;strong&gt;no optimization without a per-stage timing breakdown, and no quality change without a metric that can veto it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The video path had the first half. &lt;code&gt;bench-video-upscale.mjs&lt;/code&gt; reports per-frame milliseconds across every output tier, with the decode/inference/encode/paste split, on real clips. What it did not have was a quality number. Its &lt;code&gt;outputPsnr&lt;/code&gt; measures the deviation between "frame reuse on" and "frame reuse off" — a regression guard for a specific optimization, not a statement about how the picture compares to the truth. So "is our video upscaling any good?" was, scientifically speaking, a vibe.&lt;/p&gt;

&lt;p&gt;The fix was a ground-truth bench (&lt;code&gt;bench-video-quality.mjs&lt;/code&gt;). We take our natural test photos at 800×500 as ground truth, synthesize a low-resolution source by downsampling each one by a factor (f = 2, 4, 8), run the &lt;strong&gt;exact production geometry&lt;/strong&gt; on it, bring the result back to ground-truth size, and score PSNR, SSIM, and high-frequency variance against the original. Same seed, same model, same tiles — only the geometry changes between rows.&lt;/p&gt;

&lt;p&gt;One reading caveat, stated up front, because it nearly fooled us: Real-ESRGAN is a &lt;em&gt;perceptual&lt;/em&gt; GAN. PSNR actively under-rates it against a dumb bicubic resize (at f = 4 on a clean source: 23.57 dB versus bicubic's 25.13 dB), so these numbers must never be used to argue "AI versus bicubic." They are only valid for model-against-itself comparisons — and there, they are exactly what we needed. Two independent metrics (PSNR and SSIM) agreeing in direction is our bar for believing a result.&lt;/p&gt;

&lt;p&gt;The ruler immediately found something embarrassing. Our geometry was resizing the source &lt;em&gt;down&lt;/em&gt; to a quarter of the target — 960px wide for a 4K output — and handing that to the model. Any real detail living between that quarter-target resolution and the source's actual resolution was being thrown away before the AI ever saw it. We had been carefully building a pipeline that discarded the user's own pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: The dial nobody had turned
&lt;/h2&gt;

&lt;p&gt;Feeding the model a bigger input means it does its ×4 on a larger frame, and we then scale the result back down to the target. That sounds like extra work for nothing — the model was already being asked to upscale. It is not nothing: at the larger input, the source's genuine detail is still present, the ×4 invents detail around it, and the average of that dense 4× field down to target size quietly smooths out the model's own artifacts.&lt;/p&gt;

&lt;p&gt;We swept the input scale (m = 1.0 is what we shipped before) across all four images and all three downsampling factors — twelve rows, averaged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AI input scale&lt;/th&gt;
&lt;th&gt;Δ PSNR&lt;/th&gt;
&lt;th&gt;Δ SSIM&lt;/th&gt;
&lt;th&gt;Cost (per frame)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;m = 0.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−1.88 dB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−0.0452&lt;/td&gt;
&lt;td&gt;0.26×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;m = 1.0 (what we had)&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;m = 1.5 (shipped)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.93 dB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.0118&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.01×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;m = 2&lt;/td&gt;
&lt;td&gt;+1.64 dB&lt;/td&gt;
&lt;td&gt;+0.0186&lt;/td&gt;
&lt;td&gt;3.58×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;m = 3&lt;/td&gt;
&lt;td&gt;+1.95 dB&lt;/td&gt;
&lt;td&gt;+0.0159&lt;/td&gt;
&lt;td&gt;8.18×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;native source width&lt;/td&gt;
&lt;td&gt;+1.12 dB&lt;/td&gt;
&lt;td&gt;+0.0168&lt;/td&gt;
&lt;td&gt;1.48×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows killed their own ideas, and one needs a warning label. Start with the last row: "just feed the model the source at its own width" looks like a better trade than m = 1.5 — more gain for less cost — but it isn't a setting, it's a &lt;em&gt;relationship&lt;/em&gt;. The input scale that equals your source's own width changes with every source-to-target ratio (2 at f = 2, 1 at f = 4, 0.5 at f = 8), and at the small end it degenerates into the m = 0.5 we are about to bury. We tested the intuition behind it directly — "stop pre-upsampling past the source" — and at f = 8 the geometry we ship still beat it, by 0.6–0.8 dB on three of the four test images (and gave back 0.3 dB on the fourth). Pre-upsampling is not a bug we forgot to remove.&lt;/p&gt;

&lt;p&gt;The top row buries a piece of folklore we had been carrying around for months: "feed the model less resolution and let it work harder" costs 1.88 dB on average. That direction is dead, and we wrote it into the runbook so nobody re-proposes it in six months.&lt;/p&gt;

&lt;p&gt;m = 1.5 was the row that shipped, for a boring reason: it was &lt;strong&gt;non-negative on every single one of the twelve rows&lt;/strong&gt; — +1.88 dB at f = 2, +0.77 dB at f = 4, +0.14 dB at f = 8. An optimization that can't go backwards is an optimization you can hand to users as a choice. Note the cost column: it follows the &lt;em&gt;area&lt;/em&gt; of the AI input (1.5² = 2.25 nominal, 2² = 4, 3² = 9) rather than the tile count, and every number in it is measured, not assumed.&lt;/p&gt;

&lt;p&gt;So the option became a three-position dial. &lt;strong&gt;Fast&lt;/strong&gt; is the old geometry. &lt;strong&gt;Balanced&lt;/strong&gt; is m = 1.5. &lt;strong&gt;Best&lt;/strong&gt; is m = 2, where the curve starts bending — another +0.7 dB for nearly double the time. We also stopped asking users to guess what their machine can afford. The default position comes from device capability plus the clip in front of you. No WebGPU — where the fallback WASM path costs roughly 25× per frame — or a low-end device, and you land on Fast automatically. Otherwise we estimate each tier's finish time from the file you actually dropped: its duration and frame budget, with the real fps probed when the estimate is close enough to matter. The most expensive tier that still fits its time budget wins. A six-second 720p clip on a laptop with a real GPU defaults to Best. A four-minute 4K export defaults to Fast, without anyone having to be told why, and the number next to the buttons updates the moment you change the target. It is not a marketing figure; it is arithmetic, and it happens to be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: Teaching the upscaler to skip the boring parts
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable shape of the speed problem: we profiled everything, and inference is &lt;strong&gt;97%&lt;/strong&gt; of per-frame time. Decoding is nearly free (the OS media engine does it on silicon). Encoding is nearly free. Pasting tiles to a canvas is noise. There is exactly one lever, and it is not "make inference faster" — it is "don't run inference". Video is full of frames that did not change.&lt;/p&gt;

&lt;p&gt;We already had frame-level reuse for this: compare each frame against the last one we actually ran through the model, and if it hasn't moved, reuse the previous output. The implementation detail that cost us real time is worth repeating because it is counterintuitive: &lt;strong&gt;the comparison cannot be byte equality.&lt;/strong&gt; Decoded H.264 static frames are not bit-identical — we measured a max delta around 9 and a mean of 0.02 between consecutive "identical" frames, because the encoder makes slightly different decisions per frame. Byte comparison scores 0% hits on video that is visually frozen. The fix is an &lt;em&gt;anchored, bounded&lt;/em&gt; comparison: each frame is compared to the last frame that really went through inference (not to its predecessor, so deviation can't chain), and a hit reuses that anchor's output without moving the anchor. Bounded tolerance, no drift. This is the same discipline we landed on for video watermark removal in &lt;a href="https://clearpix.org/blog/anchor-frame-skip-redesign" rel="noopener noreferrer"&gt;part 7&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The numbers on real clips at τ = 1.0:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Hit rate&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;th&gt;Deviation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static 1080p clip&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;4.72–7.02×&lt;/td&gt;
&lt;td&gt;46.9–50.3 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow pan (1px/frame)&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;~1.97×&lt;/td&gt;
&lt;td&gt;64.2 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera moving (&lt;code&gt;clean.mp4&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And then the obvious follow-up question, which took us embarrassingly long to ask: what does real footage actually look like? Talking heads. Screen recordings with a cursor. Product close-ups. All of them are subject-motion clips — a static background with something moving in the middle — and every one of them gets a &lt;strong&gt;0% hit rate at frame level&lt;/strong&gt;. The most common shape of video on the internet was the shape our optimization couldn't touch. One moving person flips the entire frame back to full inference, forever.&lt;/p&gt;

&lt;p&gt;So we pushed the same idea down a level. The frame is already processed in 256px tiles internally, so each tile now keeps its own anchor and gets its own bounded comparison. A tile that hasn't changed copies its core region straight from the previous output canvas — no format conversion, no inference, no paste — while the tiles that did change go through the model as usual. Anchors still don't update on a hit, so the drift bound is the same as the frame-level version.&lt;/p&gt;

&lt;p&gt;Measured in isolation on synthetic clips (24 frames, 1080p tier = 4 tiles), the results split cleanly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Frame-level&lt;/th&gt;
&lt;th&gt;Tile-level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fully static&lt;/td&gt;
&lt;td&gt;19.4×&lt;/td&gt;
&lt;td&gt;9.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static background + moving subject&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.79×&lt;/strong&gt; (63/96 tiles)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole-frame pan&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;td&gt;0.99×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the honesty check: when nothing is reusable, tile-level costs about 1% (one comparison per tile) and correctly returns nothing. The middle row is the whole point — the 1.79× comes with a deviation of 49.77 dB against no-reuse output, which is a fraction of one 8-bit level. Invisible. And since frame-level is strictly better when the whole frame is static, we ship them stacked: frame first, tiles as the fallback layer. Users never choose; the picture decides.&lt;/p&gt;

&lt;p&gt;One trap almost shipped, and it's a good argument for building the bench before the feature. Pasting a tile back has to use the &lt;em&gt;core&lt;/em&gt; region — the 8-pixel overlap around each tile exists only to give the model context, and must not be written to the canvas. &lt;code&gt;putImageData&lt;/code&gt;'s dirty rectangle is relative to the source &lt;code&gt;ImageData&lt;/code&gt;, not to the destination, so the destination offset has to be the tile origin; pass the core origin instead and every tile lands 32 pixels off. Our PSNR gate caught the shift: 54 dB became 16.9 dB. Visually, "32px of double-exposed edge" reads as slightly soft — exactly the kind of thing that ships and gets discovered by a user a month later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the privacy part is structural, not a policy
&lt;/h2&gt;

&lt;p&gt;Everything above runs in your tab: hardware decode via WebCodecs, inference via ONNX Runtime Web on WebGPU, muxing via MediaBunny, hardware encode back to H.264. There is no server component of the video path at all. The only bytes that travel are the model weights coming down — once, then cached in IndexedDB, after which the tool works offline — plus a handful of cookieless analytics events: an event name and coarse properties like backend and elapsed seconds, the full list published on our &lt;a href="https://clearpix.org/privacy" rel="noopener noreferrer"&gt;privacy page&lt;/a&gt;. No media, no filenames, no durations tied to a person.&lt;/p&gt;

&lt;p&gt;That is a stronger claim than "we don't store your videos," and it is checkable from the outside: open devtools, drop a clip in, watch the network tab. Models come down; nothing goes up. This is the part that gets described as a marketing position by people who haven't tried the alternative, and it genuinely costs us — a server GPU would make the long-clip problem evaporate, and a queue would let us serve 4K to a five-year-old phone. It would also mean your footage crossing the wire to be upscaled, and that is the one trade we don't make. If you're upscaling something you wouldn't email to a stranger — a family video, an unreleased product shot, a client's footage under NDA — the tool's whole job is running on the laptop you already trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the walls are
&lt;/h2&gt;

&lt;p&gt;Per-frame cost on our reference machine (Apple GPU, WebGPU, moving content): 720p ≈ 115 ms, 1080p ≈ 263 ms, 2K ≈ 464 ms, 4K ≈ 1.06 s. Inference is 97% of each of those numbers, which is why this post is about doing less inference rather than doing inference faster.&lt;/p&gt;

&lt;p&gt;We also removed the hard duration cap this round. There's now a size guard (200 MB) and a frame budget, an honest per-clip estimate shown up front, and if the estimate exceeds eight minutes we ask instead of refusing. Slow is a conversation now, not an error message.&lt;/p&gt;

&lt;p&gt;Dead ends, recorded so we don't walk them again: multi-worker parallelism is a no-op (inference is memory-bandwidth-bound; more workers only fight over the bus), bigger tiles are a wash, IOBinding buys 1–2% so we skipped it, feeding the model less resolution costs 1.88 dB, and detail injection at k = 0.7 helps clean sources but re-amplifies compression noise on real ones, so it stays on the bench with no perceptual metric to justify it. One more for the file: if you benchmark two variants back to back, the first one silently absorbs the video encoder's cold start and looks 2.17× faster for free. Warm up first or you will ship the wrong winner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;No account, no upload, no watermark on the output: &lt;a href="https://clearpix.org/upscale-video" rel="noopener noreferrer"&gt;upscale a video to 4K in your browser&lt;/a&gt; — the tier dial and the estimate are under the target selector, and the frame budget will tell you the truth before you commit. The &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;in-browser image upscaler&lt;/a&gt; shares the model and the tile engine, the &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;free video watermark remover&lt;/a&gt; shares this exact pipeline, and if the transport layer is the part you're here for, &lt;a href="https://clearpix.org/blog/webcodecs-mediabunny-vs-ffmpeg-wasm" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; is the read.&lt;/p&gt;

&lt;p&gt;The nine seconds and the plastic skin were the same complaint. This round's version of that: "fast" and "sharp" and "private" were never three separate problems — they were one pipeline, observed from three angles.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 11 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Browser Image Upscaling: How We Got 4 Super-Resolution 14 Faster — Then Made It Slower on Purpose</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Thu, 15 Oct 2026 14:55:12 +0000</pubDate>
      <link>https://dev.to/sharksevenro/browser-image-upscaling-how-we-got-4x-super-resolution-14x-faster-then-made-it-slower-on-purpose-3olj</link>
      <guid>https://dev.to/sharksevenro/browser-image-upscaling-how-we-got-4x-super-resolution-14x-faster-then-made-it-slower-on-purpose-3olj</guid>
      <description>&lt;p&gt;Two complaints arrived in the same week. The first was about time: upscaling a 2048px photo took nine seconds, even on the WebGPU path, and users on weaker hardware saw the progress bar crawl. The second was about taste: the results looked &lt;em&gt;over-cooked&lt;/em&gt; — skin turned plastic, edges grew light halos, and if your source was a JPEG, the compression blocks came back louder than before. One complaint asked us to go faster; the other, if you squinted, asked us to add less. This is the story of how we chased the first one honestly, and why the fix for the second one ended up being a dial that gives some of that speed back.&lt;/p&gt;

&lt;p&gt;We build ClearPix, the in-browser media toolbox from parts 1–9 of this series — no uploads, no server GPUs, Real-ESRGAN-class super-resolution running inside a browser tab. If you haven't followed along: our upscaler had already been through one generational swap (the 67MB RRDBNet flagship for a 4.9MB compact variant that ran 14–27× faster and &lt;em&gt;won&lt;/em&gt; the quality shootout anyway) and one quantization gamble (fp16 weights, another 4.8× on top, told in part 3). What follows is the next chapter, where the easy wins were gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: Build the ruler before touching anything
&lt;/h2&gt;

&lt;p&gt;We have a house rule by now, earned the expensive way: &lt;strong&gt;no optimization starts without a per-stage timing breakdown, and no quality change without a metric that can veto it.&lt;/strong&gt; The upscale path had speed numbers but no quality numbers at all — "over-sharpened" was a vibe, not a measurement. So before touching a line of code we built the bench: 15 cases, five content types (people, structure, texture, sharp edges, flat gradients) crossed with three degradations (clean source, JPEG q70, JPEG q40), each scored for PSNR, SSIM, and a high-frequency ratio against the ground truth.&lt;/p&gt;

&lt;p&gt;Only then did we learn what the second complaint actually was. Everyone, us included, had assumed "over-sharpened" meant edge halos — the classic ringing artifact. The bench said otherwise: on genuinely sharp content our output was actually &lt;em&gt;too soft&lt;/em&gt; (hfRatio 0.29, meaning it had lost high frequency versus the original). The real villain was hiding in boring places: &lt;strong&gt;flat and soft regions&lt;/strong&gt;, where the model hallucinates texture that was never there, and &lt;strong&gt;JPEG artifacts&lt;/strong&gt;, which it amplifies faithfully. On flat gradients the hallucination metric climbed monotonically with source degradation — 3.47 on clean, 4.70 on JPEG q70, 5.55 on JPEG q40. Your compressed screenshot didn't just get upscaled; it got its blockiness upscaled too.&lt;/p&gt;

&lt;p&gt;That number, 5.55, deserves a footnote because it nearly sent us chasing ghosts. High-frequency &lt;em&gt;ratio&lt;/em&gt; divides by the original's high-frequency energy — and on a nearly flat original, that denominator is almost zero, so the ratio explodes regardless of absolute damage. We added an absolute metric (hfAbs) alongside the ratio and confirmed the flat-region hallucination is 0.12–0.16 intensity levels: real, but invisible. Half of "fixing" a problem is making sure you can see it accurately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: The speed chase — three honest failures and one three-line win
&lt;/h2&gt;

&lt;p&gt;With the bench as our safety net, we profiled. The per-stage breakdown held a surprise: model inference was 62% of wall time, sure — but pasting results back into the output canvas was 36%, and format conversion 1%. A third of our runtime was a CPU loop copying pixels, not AI at all.&lt;/p&gt;

&lt;p&gt;That loop did per-channel &lt;code&gt;Math.max(0, Math.min(255, value))&lt;/code&gt; clamping while writing into a &lt;code&gt;Uint8ClampedArray&lt;/code&gt; — which clamps on assignment by spec. Deleting the redundant math took three lines and bought &lt;strong&gt;88ms → 68.4ms per image (−22%)&lt;/strong&gt;, with output that is bit-for-bit identical. It remains my favorite kind of optimization: zero risk, instant payback, embarrassing only in hindsight.&lt;/p&gt;

&lt;p&gt;The natural next step was pipelining — hide the paste behind the next tile's inference, the classic overlap. We built it, verified bit-identical output, measured… nothing. Wall time didn't move. The reason was quietly funny: the pipeline was designed to hide a 36% bottleneck that the three-line fix had just reduced to 4%. We reverted the whole thing the same day and wrote the lesson into our runbook: &lt;strong&gt;re-profile after every fix; never parallelize against a profile that's already out of date.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bigger tiles, then? 384px instead of 256 would mean fewer of them. On our Apple-silicon GPU it was a wash — 941/849ms against 888/825ms — the GPU was already saturated at 256. The nuclear option was skipping half the work: feed the model a half-resolution input and let ×2 cover the rest. It was glorious — 205ms down to 55ms per tile, a 3.8× speedup — and the bench killed it just as fast: photos lost 2.6–2.9dB, sharp content lost 6.5dB. The four-times cost is what quality-first upscaling &lt;em&gt;is&lt;/em&gt;. We recorded the falsification prominently so nobody (especially future us) would re-propose it in six months.&lt;/p&gt;

&lt;p&gt;A final systems check closed the case: every operator in the model runs natively on ORT's WebGPU backend with zero fallback warnings, and 115ms for a 256² tile is simply what this architecture costs on this hardware. Scheduling layer: squeezed dry. Model candidates: falsified. Runtime: current. The remaining 97% of inference time belongs to upstream — ORT's WebGPU evolution, or a hand-written native WebGPU super-res kernel, which is a project unto itself. We stopped, which is its own discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: The honest dial
&lt;/h2&gt;

&lt;p&gt;So we were as fast as honesty allowed, and still slightly over-cooked. The model's hunger for high frequency is baked into its training — SRVGGNet is a GAN-family network, and "adds crisp detail" is literally its job description. We couldn't retrain it. What we &lt;em&gt;could&lt;/em&gt; do is scale what comes out.&lt;/p&gt;

&lt;p&gt;The fix is almost comically small: a separable 2-pixel box filter applied to the model's output at final resolution, with a single gain factor &lt;code&gt;k&lt;/code&gt;. &lt;code&gt;k = 1&lt;/code&gt; is the raw model. We swept &lt;code&gt;k&lt;/code&gt; across the bench and the results settled a small argument in the team: k = 0.8 — giving back 20% of the output's high-frequency energy — &lt;em&gt;improved&lt;/em&gt; PSNR on photos (+0.33dB on people, +0.44dB on texture, +0.27dB on edges) with SSIM flat-to-up. Read that again: &lt;strong&gt;the super-resolution model was adding high-frequency detail to photographs that the ground truth didn't have, and removing some of it made the output measurably more truthful.&lt;/strong&gt; "Over-sharpened" wasn't user pickiness; it was a real, quantified bias. The default became k = 0.8, shipped with a 60–100% slider because taste is personal and JPEG-free line art genuinely likes 1.0.&lt;/p&gt;

&lt;p&gt;Two adjacent ideas got proper burials. Rebuilding the output with matched film grain (to mask the flat-region hallucination) turned out to be unnecessary — the hallucination is k-immune but, per the absolute metric, invisible anyway. And Real-ESRGAN's official "denoise" model variants, which we hoped would be a gentler built-in preset, turned out to be detail-reconstruction &lt;em&gt;strength&lt;/em&gt; knobs — the "denoise" setting adds &lt;em&gt;more&lt;/em&gt; high frequency, not less. Both are archived in the bench directory with their falsification data, which is where good rejected ideas live.&lt;/p&gt;

&lt;p&gt;One deliberate gap: the video upscaler shares this kernel but doesn't get the slider yet, because the sharpening helper costs 961ms at 4K — more than the inference itself. It needs a GPU-side implementation before it's worth anyone's frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the walls are
&lt;/h2&gt;

&lt;p&gt;Today the pipeline is: clamp input, split into 256px tiles with 8px overlap, run the 2.4MB fp16 compact model on WebGPU, paste back only the centers so tile seams never show, then one honest pass of the sharpening dial. A 2048px photo costs about a second of GPU time on a modern laptop, and the output is — by our own bench, which exists precisely so we can say this — slightly closer to the truth than the raw model wanted to be.&lt;/p&gt;

&lt;p&gt;The nine seconds became one, and the plastic skin became a preference slider. Both complaints, it turned out, were the same complaint: the model was working harder than the truth required. Speed and honesty weren't a trade-off — they were the same fix, applied at different layers.&lt;/p&gt;

&lt;p&gt;If you want to see it yourself: &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;clearpix.org/upscale-image&lt;/a&gt; runs the whole pipeline in your tab — the sharpen slider is under the result. The tile-and-overlap engine and memory walls behind the other half of this story are in &lt;a href="https://clearpix.org/blog/running-lama-realesrgan-in-the-browser" rel="noopener noreferrer"&gt;part 5&lt;/a&gt;, the fp16 quantization adventure in &lt;a href="https://clearpix.org/blog/fp16-onnx-webgpu-browser-inference" rel="noopener noreferrer"&gt;part 3&lt;/a&gt;, and the delivery layer that gets the 2.4MB model into your browser in &lt;a href="https://clearpix.org/blog/brotli-wasm-models-r2-delivery" rel="noopener noreferrer"&gt;part 9&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 10 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>browser</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>Brotli for WASM and ONNX Models: Cutting First-Run Downloads From 72MB to 13MB, and the Compatibility Call We Made on Purpose</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:42:48 +0000</pubDate>
      <link>https://dev.to/sharksevenro/brotli-for-wasm-and-onnx-models-cutting-first-run-downloads-from-72mb-to-13mb-and-the-2ik7</link>
      <guid>https://dev.to/sharksevenro/brotli-for-wasm-and-onnx-models-cutting-first-run-downloads-from-72mb-to-13mb-and-the-2ik7</guid>
      <description>&lt;p&gt;We build ClearPix, a set of media cleanup tools that run entirely in the browser. That means every engine we ship — the ONNX Runtime WebGPU backend (a 27MB &lt;code&gt;jsep.wasm&lt;/code&gt; plus a 14MB core), the ffmpeg core for video (30.7MB), and the model zoo behind our inpainting, upscale, and detection tools (up to a 198MB LaMa) — is downloaded into a stranger's browser tab before anything useful happens. The download path &lt;em&gt;is&lt;/em&gt; the product experience. This post is about the delivery layer we ended up with: pre-compressed brotli objects on Cloudflare R2, why we compress WASM hard and ONNX barely, the progress-bar bug that fell out of it, and the compatibility decision we made consciously instead of by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually constrains model delivery
&lt;/h2&gt;

&lt;p&gt;Three hard facts shaped everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages has a 25MB per-file limit.&lt;/strong&gt; Our biggest files (27–198MB) can't even live in the deploy bundle. The build strips them, so a "mirror chain" isn't an optimization — it's the only way the product works at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public mirrors are a fallback, not a source.&lt;/strong&gt; We learned this in production on 2026-09-26: huggingface.co was unreachable from some networks, and hf-mirror's CORS headers were inconsistent (sometimes &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;, sometimes nothing), so model downloads simply failed for real users. Everything large now lives in an R2 bucket we control — 10GB free, zero egress fees, one controllable origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fallback path gets real traffic.&lt;/strong&gt; For the ORT runtime binaries we probe jsDelivr first (measured roughly 7× the throughput of our R2 custom domain) and fall back to R2 when the CDN is blocked — which happens in entire network regions. So the R2 path is not a theoretical safety net; for a slice of users it &lt;em&gt;is&lt;/em&gt; the path. We measured our own access too: the default &lt;code&gt;pub-*.r2.dev&lt;/code&gt; URL crawls at 75–110 KB/s, while a same-zone custom domain (&lt;code&gt;static.clearpix.org&lt;/code&gt;) sustains ~2.3 MB/s — a 20–30× difference that has nothing to do with compression.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Brotli: compress at upload, not at request time
&lt;/h2&gt;

&lt;p&gt;R2 doesn't transcode on the fly. You store bytes; it serves bytes. So we compress at upload time in the setup script, using nothing but Node's built-in zlib:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;br&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;brotliCompressSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;BROTLI_PARAM_QUALITY&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// wrangler r2 object put ... --content-encoding br --content-type application/wasm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object key doesn't change, &lt;code&gt;Content-Type&lt;/code&gt; stays &lt;code&gt;application/wasm&lt;/code&gt;, and the browser transparently decodes &lt;code&gt;Content-Encoding: br&lt;/code&gt; before the bytes ever reach ONNX Runtime or our model loader. Zero runtime changes — the URLs are identical, SHA-256 pins validate the decoded bytes, and IndexedDB caches the decoded bytes.&lt;/p&gt;

&lt;p&gt;Measured ratios at quality 11, on our real files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;Brotli&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ort-wasm-simd-threaded.jsep.wasm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;27.0 MB&lt;/td&gt;
&lt;td&gt;3.8 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ort-wasm-simd-threaded.wasm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;14.2 MB&lt;/td&gt;
&lt;td&gt;2.2 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ffmpeg-core.wasm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30.7 MB&lt;/td&gt;
&lt;td&gt;6.9 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;migan.onnx&lt;/code&gt; (inpainting)&lt;/td&gt;
&lt;td&gt;28.2 MB&lt;/td&gt;
&lt;td&gt;23.1 MB&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yolo11_watermark_det.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10.6 MB&lt;/td&gt;
&lt;td&gt;8.8 MB&lt;/td&gt;
&lt;td&gt;84%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lama_fp32.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;198.4 MB&lt;/td&gt;
&lt;td&gt;179.2 MB&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;real_esrgan_x4.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;63.9 MB&lt;/td&gt;
&lt;td&gt;58.6 MB&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ppocr_v5_mobile_det.onnx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.5 MB&lt;/td&gt;
&lt;td&gt;4.2 MB&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is physics, not tuning: WASM is code — structured, repetitive, full of zeros — and crushes to 14–22%. ONNX models are mostly float32 weights, which are close to entropy-maximal noise; brotli shaves 8–18% and that's the ceiling. (Worth knowing before you promise "50% smaller models" somewhere.) The full archive, all ten files, drops from 384MB to 293MB — but the number users feel is the first-run set: ORT WASM + ffmpeg went from 71.9MB to 13.0MB, an 82% cut on the critical path. Upload-side cost is a one-time ~10 minutes of q11 compression; quality 11 matters for a file served a million times, not for one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug this introduced: progress bars that lie
&lt;/h2&gt;

&lt;p&gt;Compression broke one thing, and it's worth calling out because it's subtle. When a response carries &lt;code&gt;Content-Encoding: br&lt;/code&gt;, the &lt;code&gt;Content-Length&lt;/code&gt; header is the &lt;em&gt;compressed&lt;/em&gt; size, but a streaming reader in JavaScript receives &lt;em&gt;decoded&lt;/em&gt; bytes. Our model loader's progress bar counted decoded bytes against the compressed total — so a 198MB model served as 179MB of brotli would blow straight past "100%" and keep climbing.&lt;/p&gt;

&lt;p&gt;The fix is three lines: if &lt;code&gt;Content-Encoding&lt;/code&gt; is present, ignore &lt;code&gt;Content-Length&lt;/code&gt; and use the spec's known original size (&lt;code&gt;expectedSize&lt;/code&gt;) as the denominator. Decoded bytes over original size — progress is honest again. Integrity checking was never affected: we hash the decoded bytes against pinned SHA-256s, so a compressed mirror and an uncompressed mirror are interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: br-only, no negotiation, on purpose
&lt;/h2&gt;

&lt;p&gt;Here's the call we get asked about. An object uploaded with &lt;code&gt;content-encoding: br&lt;/code&gt; is served that way to &lt;em&gt;every&lt;/em&gt; request — R2 stores the encoded bytes and doesn't do content negotiation. A client that can't decode brotli gets undecodable bytes. We could have stored two variants (identity + &lt;code&gt;.br&lt;/code&gt;) and put a ~50-line Worker in front of the custom domain to switch on &lt;code&gt;Accept-Encoding&lt;/code&gt;, with &lt;code&gt;Vary&lt;/code&gt; headers and doubled storage.&lt;/p&gt;

&lt;p&gt;We didn't. The reasoning, in one table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Brotli support&lt;/th&gt;
&lt;th&gt;Our runtime baseline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;50 (2016)&lt;/td&gt;
&lt;td&gt;WebGPU since 113 (2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;44 (2016)&lt;/td&gt;
&lt;td&gt;WebGPU since 141 (2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari&lt;/td&gt;
&lt;td&gt;11 (2017)&lt;/td&gt;
&lt;td&gt;WebGPU since 26 (2025)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge&lt;/td&gt;
&lt;td&gt;15 (2017)&lt;/td&gt;
&lt;td&gt;113 (2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A browser that can't decode brotli is from 2016 or earlier. A browser that can run ONNX Runtime Web with WASM SIMD and WebGPU is from 2023 or later. The intersection of "can't decode br" with "can boot our app" is &lt;em&gt;empty&lt;/em&gt; — the fallback we'd be building serves users who are stopped two checkpoints earlier anyway. Building dual-variant + negotiation to protect a theoretical client is solving a problem we don't have, at the cost of a Worker in the request path and an ops surface we have to maintain forever. If that ever changes — say, a genuinely non-br embedded client appears — the dual-variant design is a clean add-on, and we wrote down exactly how we'd do it.&lt;/p&gt;

&lt;p&gt;The honest caveat, for your own tooling rather than users: anything that fetches from R2 with plain &lt;code&gt;curl&lt;/code&gt; needs brotli support (&lt;code&gt;curl --compressed&lt;/code&gt;), or it saves compressed bytes to disk. Fine for scripts, worth documenting.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing we still don't love: ORT WASM has no persistent cache
&lt;/h2&gt;

&lt;p&gt;Our ONNX models and ffmpeg core have a two-level cache: immutable HTTP headers plus IndexedDB with self-healing SHA-256 verification — download once, keep forever, survive releases. The ORT runtime WASM is different: ONNX Runtime fetches it internally, so we can't feed it from IndexedDB, and its only cache layer is the browser's HTTP cache. Mobile browsers evict that under storage pressure, and our release cadence happens to match the eviction cadence — which is why "every release makes users re-download the WASM" is an illusion we'd like to kill properly with a Service Worker and Cache Storage. That's the next post in this layer of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything described here runs in production: the dual-engine MI-GAN/LaMa watermark remover at &lt;a href="https://clearpix.org/remove-watermark" rel="noopener noreferrer"&gt;clearpix.org/remove-watermark&lt;/a&gt; (the 198MB LaMa is the opt-in HQ engine — never prefetched), the Real-ESRGAN upscaler at &lt;a href="https://clearpix.org/upscale-image" rel="noopener noreferrer"&gt;clearpix.org/upscale-image&lt;/a&gt;, and the WebCodecs/ffmpeg pipelines that motivated the ffmpeg core over at &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;clearpix.org/remove-video-watermark&lt;/a&gt;. Open devtools on any of them: the only bytes on the wire are the engines and models themselves.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 9 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>cloud</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Watermark Removal Artifacts: The "Processed Look" Came From Our Own Blend, Not the AI Model</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Mon, 12 Oct 2026 23:23:27 +0000</pubDate>
      <link>https://dev.to/sharksevenro/watermark-removal-artifacts-the-processed-look-came-from-our-own-blend-not-the-ai-model-feb</link>
      <guid>https://dev.to/sharksevenro/watermark-removal-artifacts-the-processed-look-came-from-our-own-blend-not-the-ai-model-feb</guid>
      <description>&lt;p&gt;This is part 8 of the ClearPix engineering series, and it is the story of a bug that lived in the last 40 lines of the pipeline: the step that pastes the inpainted result back into the image. Users kept telling us some photos "look processed" — a soft, smeared halo around where the watermark used to be. We spent a while suspecting the model's fill quality. The model was mostly innocent. Our blending band was taking the model's full-crop reconstruction — which is &lt;em&gt;not&lt;/em&gt; the original image outside the hole — and smearing it into pixels that had been perfectly fine. And the metric we used to accept changes was structurally incapable of seeing it, because it averaged the hole and its surroundings into one number.&lt;/p&gt;

&lt;p&gt;If you take one thing home, take this: &lt;strong&gt;before optimizing a generative pipeline, split your quality metric by region — inside the mask, outside the mask — and treat your feathering band as a first-class suspect.&lt;/strong&gt; The fixes that fell out of that one measurement took our restored-region PSNR up, our video end-to-end score up 5dB, and dropped the damage to clean pixels to &lt;em&gt;exactly zero&lt;/em&gt; — while a plausible-sounding optimization we tried along the way got fully refuted and reverted in a single bench run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where watermark removal artifacts actually come from
&lt;/h2&gt;

&lt;p&gt;Our image inpainting pipeline (part 5 covers the model stack) ends with a paste step. The algebra of the old implementation looks harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;out = original + a · (inpaintedCrop − original)      a = max(binary mask, blur(mask, 6–22px))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is inside &lt;code&gt;inpaintedCrop&lt;/code&gt;. MI-GAN (and LaMa) reconstruct the &lt;em&gt;entire crop&lt;/em&gt;, not just the hole. Outside the mask, the model's output is a reconstruction of the original — close, but never identical (codec-level drift, color shift, softened texture). So wherever &lt;code&gt;a &amp;gt; 0&lt;/code&gt;, we are mixing model error into pixels that were clean to begin with. And because &lt;code&gt;a&lt;/code&gt; includes a Gaussian-blurred feather band that extends 6–22px &lt;em&gt;beyond&lt;/em&gt; the mask, every watermark we removed came with a rim of synthetic blur.&lt;/p&gt;

&lt;p&gt;Here is the part that made this bug invisible: for the thin-stroke text watermarks that users complain about most, that rim is enormous relative to the watermark itself. We measured &lt;code&gt;area(dilate(mask, 12px)) / area(mask)&lt;/code&gt; across our 13-case golden set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case family&lt;/th&gt;
&lt;th&gt;Ring band area vs. mask area&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small/faint text watermarks&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.8 – 6.5×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large text&lt;/td&gt;
&lt;td&gt;1.8 – 2.9×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dark badges&lt;/td&gt;
&lt;td&gt;1.2 – 2.0×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solid logos&lt;/td&gt;
&lt;td&gt;0.4 – 0.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a small text watermark, the majority of "the area we touched" was never watermark at all — it was the transition band. That is the processed look, quantified.&lt;/p&gt;

&lt;p&gt;It also retroactively explained a contradiction in our own tuning log: dilating masks by 2–4px had measured &lt;strong&gt;−0.5dB&lt;/strong&gt; on our golden set, which we had recorded as "expansion is bad." The real mechanism was the opposite of what we concluded — dilation itself is fine, but every pixel you add to the mask becomes a pixel the blend can pollute. The same measurement that punished dilation would reward removing the pollution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics that cannot see direction
&lt;/h2&gt;

&lt;p&gt;Our acceptance metric, &lt;code&gt;psnrRegion&lt;/code&gt;, computed PSNR inside &lt;code&gt;dilate(groundTruth, 12)&lt;/code&gt; — the hole and its ring averaged together. A pipeline that damages the ring and a pipeline that heals the hole can post the same number. We added four split metrics before changing any pipeline code (all pure observation — the 12 shared cases re-ran bit-identical):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PSNR-in / PSNR-out&lt;/strong&gt; — restoration error inside the mask vs. in the outer ring, measured separately;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ringDelta&lt;/strong&gt; — &lt;code&gt;mean |result − input|&lt;/code&gt; in the outer ring; 0 means clean pixels were untouched;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;colorShift&lt;/strong&gt; — Lab ΔE between the filled hole and the surrounding ring;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSIM (region)&lt;/strong&gt; — because PSNR and human eyes disagree in both directions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus a new golden-set case, &lt;code&gt;halo-flat-text&lt;/code&gt;: thin-stroke text on a perfectly flat gradient with a tight ground-truth mask. On a flat background there is no texture to hide in — any out-of-band contamination shows up in the numbers with nothing to blame.&lt;/p&gt;

&lt;p&gt;And running the "harmless" check first paid for itself immediately: the current snapshot scored 23.384dB against the archived 23.824 baseline. That looked like a regression — but tracing it landed on a commit from two days earlier where we had fixed real watermark bleed-through (the blend used to put model output at ~50% alpha at thin-stroke centers, leaking half the watermark back). The fix legitimately trades metric for correctness. Knowing that &lt;em&gt;before&lt;/em&gt; touching anything is the difference between chasing ghosts and measuring progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop blending model pixels into clean ones
&lt;/h2&gt;

&lt;p&gt;The change (A4 in our plan) is small enough to state in one formula. The alpha stays &lt;code&gt;max(soft mask, hard threshold)&lt;/code&gt; — inside the mask we keep 100% model output, which is non-negotiable (the earlier fix that killed bleed-through). Outside the mask, the artificial feather band is deleted; the only blending left is the mask's own upscale ramp, a pixel or two wide:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Futd1vv1yc5l26ijt6rq3.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Futd1vv1yc5l26ijt6rq3.jpg" alt="Zoomed comparison of the same bench case processed by the old and new paste step: the before result shows a smeared halo of model reconstruction error around the removed watermark, while the after result leaves the background texture outside the mask untouched" width="800" height="115"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same image, same detection masks, same model — only the paste step differs. In the before panel, look at the ledge line and the background around the removed text: a soft band of reconstructed non-information. In the after panel the background is bit-identical to the input wherever the mask wasn't.&lt;/p&gt;

&lt;p&gt;The split metrics moved exactly the way the diagnosis said they should:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxyxqoxsknpzmfyu6orx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxyxqoxsknpzmfyu6orx.png" alt="Bar chart of ringDelta per bench case before and after the fix: every case damaged clean pixels by 2.8 to 8.2 out of 255 before, and every case measures 0.00 after" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ringDelta: 5.63 average → 0.00, on every case.&lt;/strong&gt; Not "less visible" — zero. Nothing outside the mask is touched anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PSNR-out: 30.8 → 84.3 dB&lt;/strong&gt; (most cases hit the 99dB ceiling, meaning bit-identical to the input).&lt;/li&gt;
&lt;li&gt;The halo case: 37.0 → &lt;strong&gt;49.9 dB&lt;/strong&gt; region PSNR.&lt;/li&gt;
&lt;li&gt;Overall restored-region PSNR on the 12 shared cases: 23.4 → 23.9 dB — modest, exactly as our P0 analysis predicted for cases where false-positive repainting inside the mask dominates.&lt;/li&gt;
&lt;li&gt;Video end-to-end (which shares the paste code): &lt;strong&gt;33.9 → 38.9 dB&lt;/strong&gt; masked-region PSNR, a +5dB jump from deleting a blending band.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The optimization that lied to us: when reference statistics are wrong
&lt;/h2&gt;

&lt;p&gt;With the composite layer fixed, the next target was inner color blocks — cases where the fill's average color doesn't match its surroundings (up to ΔE 14.4 on our set). The textbook move is statistical color matching: shift the model output toward the mean of the surrounding ring before pasting. It sounds airtight. It failed, and the failure is the most useful thing we learned this round.&lt;/p&gt;

&lt;p&gt;We built a scatter plot of the two references — the color difference against the &lt;em&gt;ring&lt;/em&gt; (what the alignment sees) versus the color difference against the &lt;em&gt;clean ground truth&lt;/em&gt; (what actually matters):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0ppc0r0mi01vr8y9tah.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0ppc0r0mi01vr8y9tah.png" alt="Scatter plot of per-case color shift measured against the surrounding ring versus against clean ground truth, showing the two are nearly uncorrelated: tiled-med sits at 14.4 against the ring but 0.3 against truth, while logo-br hides a 10.3 true bias behind a 1.3 ring score" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ring lies in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tiled-med&lt;/strong&gt; scored ΔE 14.4 against its ring but only &lt;strong&gt;0.3 against ground truth&lt;/strong&gt; — the fill's color was essentially perfect; the watermark band simply sits across content that genuinely differs from its surroundings. Aligning it to the ring dragged a near-perfect fill &lt;em&gt;away&lt;/em&gt; from the truth: inner PSNR fell 24.9 → 21.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;logo-br&lt;/strong&gt; was the opposite: a real color bias of &lt;strong&gt;10.3&lt;/strong&gt; hidden behind a ring score of 1.3 — the ring-based alignment could never even see it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At runtime you cannot distinguish "biased fill" from "content that legitimately differs from its surroundings." Our bench run of the alignment confirmed the split — some cases improved (text-bl's ΔE 11.9 → 2.6 for nearly free), the tiled cases degraded past our per-case gate. We reverted it the same day, per the rule we set in part 7: decide the stop line first, and treat a tripped gate as a bug report, not an opinion.&lt;/p&gt;

&lt;p&gt;What the attempt left behind is permanent: a new metric, &lt;strong&gt;ΔE-true&lt;/strong&gt; (fill vs. clean ground truth — computable offline where ground truth exists), which is now the acceptance basis for any future color-alignment work. Baseline: average 3.6, worst case 10.3. The residual color-block problem is real; it will need a mechanism that respects content boundaries (gradient-domain blending aligns &lt;em&gt;boundary continuity&lt;/em&gt; rather than forcing hole statistics), not a stronger version of the same lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snap the mask to the real edge
&lt;/h2&gt;

&lt;p&gt;The last piece targets the upstream cause of ragged edges: glyph masks are born at OCR probability-map resolution — roughly 1/4 to 1/16 of the image — so after upscaling, the boundary position is inherently off by 4–16 pixels. We added a guided filter (He et al., 2010) inside the glyph-mask builder: the detection image, resampled to the probability grid, acts as the guide, and the mask's 0.5 level set snaps to the visible watermark edge instead of the flood fill's incidental boundary.&lt;/p&gt;

&lt;p&gt;Zero plumbing changes — the product path and the bench path both consume the refined bitmap as-is, the op contract and coordinate contracts untouched, and the filter runs on the quarter-scale grid so detection time doesn't move. The perceptual metric moved most where the complaint lived:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSIM: 0.739 → 0.753 average&lt;/strong&gt;, with the largest gains exactly on the perceptually worst cases: text-br-large 0.708 → 0.770, text-huge 0.471 → 0.524, dark-text-br 0.577 → 0.615.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest caveat for anyone copying this: precision against our ground-truth masks &lt;em&gt;dropped&lt;/em&gt; slightly (47.4 → 47.0), and that is not a quality signal — the ground truth is looser than the real edge, and a mask that hugs the real edge loses points against a loose GT while covering more of what actually matters. Trust the region PSNR and SSIM pair, and always eyeball the worst case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjxzr8lvgqagit0a7xkm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjxzr8lvgqagit0a7xkm.png" alt="Bar chart of restored-region PSNR per case before and after the fixes, with the 22dB acceptance gate line: several cases improved by 1-13dB, no case regressed more than 1dB, and the flat-background halo case reached 49.9dB" width="799" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across the 13-case suite (12 shared + the new halo case), one week of composite-and-mask work, two shipped fixes and one refuted experiment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ringDelta (damage to clean pixels)&lt;/td&gt;
&lt;td&gt;5.63 avg, &amp;gt;0 everywhere&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00 everywhere&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSNR-out (outer ring)&lt;/td&gt;
&lt;td&gt;30.8 dB&lt;/td&gt;
&lt;td&gt;84.3 dB (bit-identical in practice)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restored-region PSNR (12 shared)&lt;/td&gt;
&lt;td&gt;23.4 dB&lt;/td&gt;
&lt;td&gt;24.2 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSIM (region)&lt;/td&gt;
&lt;td&gt;0.710&lt;/td&gt;
&lt;td&gt;0.753&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video e2e, masked region&lt;/td&gt;
&lt;td&gt;33.9 dB&lt;/td&gt;
&lt;td&gt;38.9 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Halo case (flat background)&lt;/td&gt;
&lt;td&gt;37.0 dB&lt;/td&gt;
&lt;td&gt;49.9 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No case regressed more than 1dB; all acceptance gates held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules we kept, one we learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Localize damage before you localize the fix.&lt;/strong&gt; A single mixed PSNR number hid a real bug for weeks and misexplained a tuning result for months. The in/out split cost one bench run to build and paid for itself on the first look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blending band is a first-class suspect.&lt;/strong&gt; Generative pipelines end with a paste, and the paste is where "the model's version of your image" meets "your image." Any alpha &amp;gt; 0 outside the hole is a place the model can be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate your reference, not just your method.&lt;/strong&gt; Statistical alignment toward a ring assumes the ring represents what the hole should look like. Sometimes it doesn't — and the failure is invisible to the very metric that motivated the method. Measure against ground truth when you have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revert cheap, keep the instrument.&lt;/strong&gt; The refuted alignment cost one bench run and left behind ΔE-true, which will judge every future attempt. That is a good trade.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All numbers in this post come from our public bench snapshots (&lt;code&gt;run-2026-10-01*.json&lt;/code&gt;), the quality dashboard at clearpix.org/quality, and the commits they were measured on — the same gates that block our releases.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 8 of the ClearPix engineering series. Earlier parts: in-browser architecture (1), WebCodecs vs ffmpeg.wasm (2), fp16 quantization (3), video frame skipping + EMA (4), LaMa and Real-ESRGAN in the browser (5), automatic watermark detection (6), the anchor-based frame-skip redesign (7).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>debugging</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Video Inpainting Optimization: We Deleted the Safety Net and Got Faster, Better Output</title>
      <dc:creator>Umasou</dc:creator>
      <pubDate>Mon, 12 Oct 2026 00:01:20 +0000</pubDate>
      <link>https://dev.to/sharksevenro/video-inpainting-optimization-we-deleted-the-safety-net-and-got-faster-better-output-1jm1</link>
      <guid>https://dev.to/sharksevenro/video-inpainting-optimization-we-deleted-the-safety-net-and-got-faster-better-output-1jm1</guid>
      <description>&lt;p&gt;This is a decision log, not a mechanism tutorial — part 4 of this series covers how our frame-skipping gate and temporal EMA work internally. Here we cover the other half of the story: why we replaced a shipped, working safety mechanism with a simpler one, how we validated the change without betting the product on it, where the first run regressed hard enough to trip our preset abort criterion, and the two small fixes that turned the redesign into a 1.5dB quality &lt;em&gt;win&lt;/em&gt; over the old baseline. If you take one thing home, take the discipline: decide your stop line before you run the experiment, and make sure your test set includes the worst case you have ever shipped a bug on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to speed up video inpainting that already works
&lt;/h2&gt;

&lt;p&gt;Some context. Our in-browser video watermark remover inpaints frames one by one, but most frames near a static watermark barely change, so a motion gate lets us skip inference and reuse an earlier result. The first version of that gate (v1) compared each frame to its immediate neighbor and reused the previous output. That design had already failed once, badly: on footage with a slow global gradient, per-frame differences stay tiny forever, the gate skips forever, and the repaired region freezes into a visible rectangle. That incident cost us 22dB on our end-to-end masked-region PSNR (38.7 down to 16.7) and earned itself a dedicated regression asset: a synthetic clip whose background drifts continuously, built precisely so no future change could reintroduce chain-freezing unnoticed.&lt;/p&gt;

&lt;p&gt;The fix we shipped was a cumulative drift budget plus a brightness-delta compensation: accumulate the error of skipped frames, force re-inference when the budget is spent, and nudge reused pixels by the scene's measured exposure shift. It worked. The frozen-rectangle bug stayed dead, quality held at 38.44dB against a 38.74 no-skip reference, and the safety net went to production.&lt;/p&gt;

&lt;p&gt;So the starting position of this story is not a broken system. It is a working one with a layer of indirection in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anchor idea: keyframe reuse in video processing
&lt;/h2&gt;

&lt;p&gt;In a design review, someone asked the question that started everything: why does each frame compare itself to its neighbor at all?&lt;/p&gt;

&lt;p&gt;Think about what the skip error actually is. When we skip a frame, we reuse the output of the last frame that really ran inference — call it the anchor. The error we are committing is exactly "how different is the current frame from the anchor." The drift budget only ever &lt;em&gt;approximated&lt;/em&gt; that quantity by summing per-step differences and hoping the steps add up. Comparing against the anchor directly measures the thing itself: the skip error is bounded by the threshold, by definition, with no accumulator, no proxy, no edge cases about whether slow drift can sneak past.&lt;/p&gt;

&lt;p&gt;The theory also predicted we would lose nothing on speed. Summing noisy per-step differences can only overcount relative to a direct comparison (oscillation and noise accumulate in the sum but cancel in the direct diff), so the skip rate should stay flat or improve. Fewer lines of state, a stronger guarantee, equal-or-better performance. Elegant ideas like this are exactly the ones that blow up in your face, so we wrote the rules down first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsq45zbphwp9w7bqq33p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsq45zbphwp9w7bqq33p.png" alt="Diagram comparing chained frame-to-frame comparison with cumulative drift budget against anchored comparison where every frame is measured directly against the last inferred keyframe" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  PSNR regression testing with a preset stop line
&lt;/h2&gt;

&lt;p&gt;Our rule for pipeline changes is that they pass the end-to-end quality gates before they ship, full stop. For this experiment we made the gate explicit &lt;em&gt;before&lt;/em&gt; running anything: the redesign would be aborted — not patched, not negotiated — if masked-region PSNR on any e2e asset dropped below 36dB. That number was chosen in advance, while we were still neutral about the outcome. Deciding the abort criterion after seeing the results is how regressions get rationalized into production.&lt;/p&gt;

&lt;p&gt;The gates themselves deserve a mention, because they are the second time this story's real hero shows up. The e2e suite includes that synthetic gradient-drift clip born from the 16.7dB chain-freeze incident — deliberately the worst case for any reuse-based optimization, with a background that never holds still but changes too slowly to trip naive motion detection — plus a four-segment subtitle asset where the masked content changes constantly. One asset attacks stale reuse, the other attacks insufficient reuse. Whatever we changed, one of them would notice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pk879xcpixx4oazkvh0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pk879xcpixx4oazkvh0.jpg" alt="Still frames from the end-to-end test assets showing the watermarked frame, the ground-truth clean frame, and zoomed crops of the masked region used for PSNR measurement" width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  First run: pass, pass, pass, stop
&lt;/h2&gt;

&lt;p&gt;The anchored rewrite went in, and the first full run was a split decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Subtitle asset: all four segments improved by +3–4dB (37.3 / 32.3 / 28.7 / 30.1dB). The direct comparison was doing exactly what theory promised on fast-changing content.&lt;/li&gt;
&lt;li&gt;Gradient-drift asset: masked-region PSNR fell from 38.30 to &lt;strong&gt;34.69dB&lt;/strong&gt; — below the 36dB stop line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the moment the discipline is for. The aggregate picture looked fine — better, even — and it would have been easy to average the results, declare victory on subtitles, and tune later. We stopped instead, exactly as agreed, and treated 34.69 as a bug report the experiment had written for us. No rollback either: the stop line meant "stop and explain," not "revert on sight."&lt;/p&gt;

&lt;h2&gt;
  
  
  Error accumulation in video processing, round two
&lt;/h2&gt;

&lt;p&gt;The root cause analysis turned up something genuinely new — a failure mode the chained design had been hiding from us.&lt;/p&gt;

&lt;p&gt;The gradient clip's background does not drift uniformly. Its exposure shift varies with vertical position (the asset synthesizes it roughly as &lt;code&gt;sin(y/60 + t·4)&lt;/code&gt; and &lt;code&gt;cos(y/80 + t·3)&lt;/code&gt; components), while our delta compensation is a single per-channel mean over the region. A mean cannot cancel a gradient. Under the old chained design, reused pixels were always exactly one frame old, so the uncompensated residual stayed small and roughly constant. Under anchoring, a reused output can be two or three frames away from its anchor, and the residual grows with anchor age. The redesign had not created the compensation error; it had &lt;em&gt;amplified&lt;/em&gt; it by extending reuse lifetimes — the same mechanism that made everything else better.&lt;/p&gt;

&lt;p&gt;We also found a second, dumber bug in the same territory. The temporal EMA blends the current output against the previous frame's output, so its history term needs compensation against the previous frame. In the rewrite we had fed it the to-anchor delta instead — a systematic overshoot on every blended pixel, because the compensation was answering a different question than the blend was asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small fixes, no rollback
&lt;/h2&gt;

&lt;p&gt;Both fixes were small and stayed inside the new design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Threshold ceiling 8 → 5.&lt;/strong&gt; The skip threshold is self-calibrating (median of the first five frames' differences, times three, clamped to [2, 5]/255). Lowering the ceiling forces re-anchoring more often, which directly bounds anchor age — attacking the residual-accumulation mechanism rather than its symptom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the deltas.&lt;/strong&gt; Compositing reuses the anchor, so it uses the to-anchor delta. The EMA blends against the previous frame, so it gets the to-neighbor delta. One line of reasoning each, no shared state pretending two timebases are one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The calibration logic itself did not change at all — only the clamp ceiling and the delta plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: beating the baseline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdch0lnigq3afi0bcmegu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdch0lnigq3afi0bcmegu.png" alt="Bar chart of masked-region PSNR across the five design generations: chain-freeze bug 16.7, drift budget only 22.9, drift budget plus delta 38.44, first anchored run 34.69, final anchored version 39.84" width="799" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The final anchored version, same gates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gradient-drift asset: &lt;strong&gt;39.84dB masked-region PSNR — 1.5dB above the chained baseline&lt;/strong&gt;, not merely recovered. With the delta semantics fixed, direct comparison and exact to-anchor compensation turned out to be strictly better than the approximation we had been defending.&lt;/li&gt;
&lt;li&gt;Subtitles: the +3–4dB gains held, with 67% of frames skipping inference.&lt;/li&gt;
&lt;li&gt;Speed: 58% of frames skipped on the watermark asset, flat versus the budget version as predicted, with the entire drift-budget code path deleted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The safety net we removed was never missed. The one we kept — the preset stop line and the worst-case test asset — caught the one regression that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the abort criterion before the experiment.&lt;/strong&gt; "Masked PSNR below 36 means stop" was decided while we were neutral. After seeing +3–4dB wins, that line is much harder to draw honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your test set should contain your worst historical bug, weaponized.&lt;/strong&gt; The gradient-drift asset was built to catch chain-freezing two years of refactors ago; it has now caught a second, completely different failure. Worst-case assets appreciate in value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregates hide regressions.&lt;/strong&gt; Subtitles up 3–4dB and gradient down 3.6dB averages to "improved." We read per-asset results first, means second, and the stop line was per-asset too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler mechanisms fail differently, not gently.&lt;/strong&gt; Deleting the accumulator removed a whole class of proxy-error bugs and introduced none — but it amplified a compensation error the old design had been silently capping. Budget analysis time for the new failure modes of elegant ideas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix forward when the root cause is small.&lt;/strong&gt; Both fixes were a few lines inside the new design. Reverting would have thrown away a real quality win to avoid understanding a small bug.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The anchored skip gate ships in &lt;a href="https://clearpix.org/remove-video-watermark" rel="noopener noreferrer"&gt;our free video watermark remover&lt;/a&gt;, and the same pipeline drives our &lt;a href="https://clearpix.org/remove-subtitles-from-video" rel="noopener noreferrer"&gt;subtitle removal tool&lt;/a&gt; — both run entirely in your browser, no uploads. If you have footage that breaks our gates, we genuinely want to hear about it; our best test assets were born that way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 7 of the ClearPix engineering series — how we build free, private, in-browser media tools at &lt;a href="https://clearpix.org" rel="noopener noreferrer"&gt;clearpix.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>performance</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
