<?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: Lank_M</title>
    <description>The latest articles on DEV Community by Lank_M (@pm_cheng_3f36acecfb9c59f5).</description>
    <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</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%2F4086794%2F931fea88-28cd-4592-bb00-cbf315e37b06.png</url>
      <title>DEV Community: Lank_M</title>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pm_cheng_3f36acecfb9c59f5"/>
    <language>en</language>
    <item>
      <title>Your HEIC upload pipeline may be storing one 512 512 tile and calling it done</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:02:25 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/your-heic-upload-pipeline-may-be-storing-one-512x512-tile-and-calling-it-done-43f7</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/your-heic-upload-pipeline-may-be-storing-one-512x512-tile-and-calling-it-done-43f7</guid>
      <description>&lt;p&gt;If your upload service converts HEIC with a plain &lt;code&gt;ffmpeg -i photo.heic out.png&lt;/code&gt;, check the dimensions of what it stored. On my test file, ffmpeg 7.1 exited cleanly, wrote a valid PNG, and that PNG was 512×512. The photo is 3264×2448. Nothing in the logs looks wrong, and a thumbnail generated from a 512 px square looks plausible enough that nobody complains until someone opens the full image and finds the top-left corner of an airport roof.&lt;/p&gt;

&lt;p&gt;A note on the sample before any numbers. I don't have a publishable iPhone original, so I took a CC0 photo from Wikimedia Commons, shot on an iPhone 6 at Madrid airport (3264×2448 JPEG), and encoded it to HEIC with macOS 26.5's built-in &lt;code&gt;sips&lt;/code&gt;. I made three variants: landscape, a copy with EXIF orientation set to 6 to act as a portrait shot, and one upscaled to 4032×3024. These are not straight-from-camera files. What they share with iPhone HEICs is the part that matters here, the 512 px tile grid and the &lt;code&gt;irot&lt;/code&gt; rotation property. I have no real-device samples for HDR gain maps or Live Photos, so none of that is covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The primary image is a grid, not a frame
&lt;/h2&gt;

&lt;p&gt;In a HEIF file the primary item isn't a single HEVC picture. It's a derived item of type &lt;code&gt;grid&lt;/code&gt; with no pixels of its own, pointing via &lt;code&gt;dimg&lt;/code&gt; references at a set of independently coded &lt;code&gt;hvc1&lt;/code&gt; tiles that the decoder is expected to stitch. The quickest way I found to see that was to walk the &lt;code&gt;meta&lt;/code&gt; box and count item types:&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;collections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;I4s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latin1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iinf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iprp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ipco&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# containers: skip their headers
&lt;/span&gt;            &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iinf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;infe&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# item type (infe v2)
&lt;/span&gt;        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;irot&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seen&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="s"&gt;irot=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&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;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;seen&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The landscape file prints &lt;code&gt;{'hvc1': 35, 'grid': 1, 'Exif': 1, 'mime': 1, 'irot=0': 1}&lt;/code&gt;: 35 tiles, 7 columns by 5 rows, plus EXIF and an XMP &lt;code&gt;mime&lt;/code&gt; item. The 4032×3024 variant has 48 tiles. The portrait one reports &lt;code&gt;irot=270&lt;/code&gt;, which comes back below.&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/..." 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/..." alt="The 35 tiles of a 3264×2448 HEIC next to the single 512×512 tile ffmpeg's default command writes" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sample: Wikimedia Commons CC0 photo (shot on iPhone 6), encoded to HEIC with the macOS system encoder.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;ffmpeg does see the grid. Its input dump contains &lt;code&gt;Stream group #0:0 Tile Grid ... 3264x2448 (default)&lt;/code&gt;. The default conversion just doesn't composite it and hands you the first tile. I tried selecting the stream group directly with &lt;code&gt;-map 0:g:0&lt;/code&gt; and got &lt;code&gt;Conversion failed!&lt;/code&gt;, and I stopped there, so I'm not going to claim a correct flag set. What I'm confident about is the guard: after conversion, compare output dimensions with the grid dimensions and fail the job if they differ.&lt;/p&gt;

&lt;p&gt;Pillow fails in a friendlier way. Pillow 11.3 without the pillow-heif plugin raises &lt;code&gt;UnidentifiedImageError: cannot identify image file&lt;/code&gt;. Loud failures get caught in staging. The one that bites is a dev machine with the plugin and a production image without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation lives in two places
&lt;/h2&gt;

&lt;p&gt;The portrait sample carries &lt;code&gt;irot&lt;/code&gt; with 270° on the primary item, so orientation is an image property the decoder applies, not only an EXIF hint. For comparison I converted it with &lt;code&gt;sips&lt;/code&gt;: the JPEG it writes keeps pixels at 3264×2448 landscape and sets EXIF Orientation to 6 (model iPhone 6 and other fields preserved). Finder and browsers honour the tag, so it looks right on your laptop. A thumbnailer that resizes raw pixels, or a privacy step that strips EXIF, will serve it sideways. The opposite mistake is just as easy in a multi-service pipeline: one step rotates the pixels, another keeps Orientation 6, and the image gets turned twice. I still don't have anything better for that than an end-to-end test with a portrait fixture.&lt;/p&gt;

&lt;h2&gt;
  
  
  JPEG can be bigger than the HEIC
&lt;/h2&gt;

&lt;p&gt;I measured sizes in the browser converter of imging, a tool I help build, since it reports before and after. The portrait sample is 745.1 KB. JPEG at the default quality 88 came out at 1.01 MB, 38% larger. The 4032×3024 sample went from 840.1 KB to 1.32 MB, 61% larger, and still 17% larger at quality 80. WebP at its default quality 84 was 423.2 KB, 43% smaller. That's one photo, so treat the percentages as a warning about defaults rather than a ratio to plan storage with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to convert
&lt;/h2&gt;

&lt;p&gt;In the browser: in Playwright's bundled engines on macOS, Chromium 149 and Firefox 151 couldn't display HEIC via &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; or &lt;code&gt;createImageBitmap&lt;/code&gt;. WebKit 26.5 could, using the system decoder. I didn't test real Safari, iOS or Windows. Covering every browser means shipping a WASM decoder. That's what imging does for reading HEIC: a one-time "load decoder" step pulls a libheif build, then stitching and rotation happen locally and the JPEG comes out as 2448×3264 pixels with Orientation 1. Writing HEIC as an output goes through its server. I didn't measure decoder size or decode time.&lt;/p&gt;

&lt;p&gt;On the server: consistent results for every client and logs you can grep. You own all of it, though: the plugin in the production image, stitching the full grid, reconciling &lt;code&gt;irot&lt;/code&gt; with EXIF, and choosing an output format and quality instead of inheriting JPEG 88.&lt;/p&gt;

&lt;p&gt;On the phone: Settings › Camera › Formats › Most Compatible. Apple's support page says new photos and videos are then captured as JPEG and H.264. That only affects future shots and depends on users changing a setting.&lt;/p&gt;

&lt;p&gt;None of these is free, and which one fits depends on your clients and how many services touch EXIF. The two asserts, full grid dimensions and rotation applied exactly once, are useful whichever you pick. If you want to compare your own HEIC files against an in-browser decode, imging is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&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%2Fy0vtirguo2rxllyogrxd.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%2Fy0vtirguo2rxllyogrxd.png" alt=" " width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Temporal video matting: steadier edges you can't see, and a lost singer you can</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:02:22 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/temporal-video-matting-steadier-edges-you-cant-see-and-a-lost-singer-you-can-27pj</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/temporal-video-matting-steadier-edges-you-cant-see-and-a-lost-singer-you-can-27pj</guid>
      <description>&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%2Fh7lt09vfrp17tmlw0q8s.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%2Fh7lt09vfrp17tmlw0q8s.png" alt=" " width="800" height="1219"&gt;&lt;/a&gt;&lt;br&gt;
The video matting page in a tool I help build runs a temporal model: RVM keeps a recurrent state from frame to frame instead of treating each frame as a fresh image. The matting model isn't my code. My part is everything around it, which means WebCodecs decode and encode, and getting 7.2 MB or 51.3 MB of ONNX weights into the tab and cached. Carrying that state through the pipeline costs real plumbing, so I wanted a number for what it buys over the obvious alternative, which is to decode every frame and hand it to the per-frame image matting we already ship. I got the number. It was smaller than I expected, and while collecting clips for it, the same design showed me a failure I hadn't been looking for.&lt;/p&gt;
&lt;h2&gt;
  
  
  Measuring flicker without ground truth
&lt;/h2&gt;

&lt;p&gt;The clip is "Tarun speaking 01" from Wikimedia Commons (CC0): 464×832, 59.94 fps, one man talking at an event, a few people moving behind him. I converted it to H.264 so it looks like a phone file, and took frames 120 to 179, one second. Temporal side: the compatibility model (RVM MobileNetV3) and the quality model (RVM ResNet50), each exporting a transparent WebM, alpha decoded back with libvpx. Per-frame side: the same 60 frames pushed one by one through our image matting on its fast setting, ISNet INT8. No two consecutive ISNet mattes were identical, so each frame really was inferred from scratch. There's no true alpha for a real clip, so I only compared how much the edge moves. The band is every pixel that was soft (alpha between 0.1 and 0.9) in any of the three runs, grown by 3 px and limited to a box around the speaker: 16,853 pixels. Background people are outside the box on purpose, because RVM keeps them and ISNet doesn't. Plain frame-to-frame change mixes flicker with motion, so the metric I trust most only counts pixels whose source colour barely changed:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;static_jitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;still&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;vals&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;still_px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;src&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&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="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;still&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;alpha&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&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="n"&gt;still_px&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&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;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;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On those still pixels ISNet moves 0.00483 per frame, MobileNetV3 0.00298 and ResNet50 0.00329, so per-frame is about 1.5× more restless. A second-order measure that cancels steady motion agrees (0.00485 against 0.00360 and 0.00344), and plotted over time the ISNet curve is a sawtooth, one frame up and the next down. Then the absolute scale: alpha moves by less than 1% on average in all three. I put four consecutive frames side by side at 3× and couldn't point at the flicker. What you can see is the edge shape. ISNet gives a soft, wide shoulder and no hair outline, and both RVM models keep strands on top of the head.&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/..." 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/..." alt="Head-and-shoulder alpha, frames 120 to 123: source, per-frame ISNet, RVM MobileNetV3, RVM ResNet50" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Clip: Wikimedia Commons "Tarun speaking 01" (CC0).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One metric went the other way. Counting only transition pixels, MobileNetV3 came out higher than ISNet (0.0363 against 0.0274). My reading is that ISNet's wide soft edge adds a lot of quiet transition pixels and dilutes its own average, but that's a guess. The comparison also leans against the temporal side, since its alpha went through lossy VP9 and ISNet's went straight to PNG. And ISNet's colour pixels came out about 6 levels darker than the source, which has nothing to do with alpha, and I still don't know why.&lt;/p&gt;
&lt;h2&gt;
  
  
  The encoder side
&lt;/h2&gt;

&lt;p&gt;This is the part I actually own, and it isn't a model choice. In the headless Chromium 149 I test with:&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;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;codec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vp09.00.10.08&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;464&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;832&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bitrate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_660_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;framerate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;59.94&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;VideoEncoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isConfigSupported&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keep&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;supported&lt;/span&gt;     &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;VideoEncoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isConfigSupported&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nx"&gt;supported&lt;/span&gt;  &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the encoder config won't give you alpha for free. WebM stores VP9 alpha as a second coded stream in BlockAdditional, and a plain ffmpeg packet count doesn't include it: the transparent export is 5.47 MB, while the main video packets ffmpeg counts add up to 2.72 MB. Take out the Opus track and roughly 2.6 MB is left, which as far as I can tell from the container is the alpha. I haven't walked the bytes to prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory also holds on to a mistake
&lt;/h2&gt;

&lt;p&gt;The second clip was "Folkloristic dance in Naples (tammorriata)", also CC0: 1280×720, 25 fps, 12.84 s of a street performance with a handheld camera panning slowly and no cuts. Compatibility model, pure green background. The lead singer stands left of centre, a bit behind the front row, in dark clothes against a crowd and metal barriers. At frame 0 his box is 68% opaque. By 3 s he's a faint shadow at 4%. Around 6 s he comes back, but see-through. From 10 s the left half of the frame holds 0.1% foreground, and from 10.4 s to the end it's zero. The four people in the front row, lighter clothes and clear outlines, stay at 37 to 44% of the right half for the whole clip.&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%2Fusr6hf9dvgw27hiehm3x.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%2Fusr6hf9dvgw27hiehm3x.png" alt="Naples clip at 0, 3, 6, 10.4 and 12.6 s: the lead singer fades, returns see-through, then disappears" width="800" height="563"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Footage: Wikimedia Commons "Folkloristic dance in Naples (tammorriata)" (CC0).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The pipeline resets the state when it detects a scene cut, and there is no cut here, so nothing forces a fresh look at the singer. My understanding, which again isn't my module, is that the same recurrence that smooths the edge also carries "not foreground" forward once the model has settled on it. I haven't run the quality model or the experimental tier on this clip, so this is one clip on the smallest model, not a statement about the others.&lt;/p&gt;

&lt;p&gt;Where that leaves me: temporal matting earns its keep on edges by a margin that's real in the numbers and small on screen, and a per-frame pipeline would at least look at the singer again on every frame, whether or not it then finds him. What I haven't worked out is whether a periodic re-check without a scene cut would bring him back or just add flicker. The page is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; if you want to throw your own clip at it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Cropping a photo and copying its EXIF back kept the uncropped thumbnail</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:03:55 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/cropping-a-photo-and-copying-its-exif-back-kept-the-uncropped-thumbnail-50f3</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/cropping-a-photo-and-copying-its-exif-back-kept-the-uncropped-thumbnail-50f3</guid>
      <description>&lt;p&gt;The standard advice for keeping metadata through an image pipeline is to read the EXIF block from the source and hand it to the encoder on save. In Pillow that is one keyword argument, &lt;code&gt;exif=im.info["exif"]&lt;/code&gt;, and it feels like the conservative option: you changed the pixels and left everything else alone. The problem is that a good share of EXIF isn't about the scene. It describes the pixel data it was written next to, and once you crop or rotate, those fields are wrong. One of them is a second picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields that describe the bytes, not the photo
&lt;/h2&gt;

&lt;p&gt;Orientation (tag 274) tells a viewer how to rotate the stored pixels for display, so if your code already rotated them, a leftover value of 6 makes every viewer that honours the tag rotate them again. PixelXDimension and PixelYDimension in the Exif IFD record the size the camera produced, and Pillow doesn't touch them when you pass the old block back in. GPS isn't tied to the pixels at all, but it is the field people least expect to ride along with an edited copy. Then there is IFD1. The Exif specification says the 1st IFD "may be used to record a thumbnail image" (I checked that wording in version 2.32; the 3.x text sits behind a download agreement and I haven't read it), so not every JPEG has one, but the iPhone photo below does. It is a small JPEG of the whole frame stored inside the APP1 segment, where an operation on the main image never looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crop, then copy the EXIF back
&lt;/h2&gt;

&lt;p&gt;I wanted ground truth first, so I generated a 4000×3000 JPEG with a script. The left half carries the text "门牌 18-3" (a house-number plate, in Chinese), the right half a sun on blue. GPS is a made-up point on the sea, and IFD1 holds a 160×120 thumbnail of the full frame. Cropping the right half with Pillow and saving with the original EXIF gives a 2000×3000 file that shows only the sun. Its embedded thumbnail is still 160×120 and still the whole frame, house number included. PixelXDimension still says 4000, and the GPS is still there.&lt;/p&gt;

&lt;p&gt;A synthetic file proves the mechanism but says nothing about real phones, so I repeated it on "Avions à l'aéroport de Madrid - 2015" from Wikimedia Commons, released under CC0, shot on an iPhone 6 on 2015-07-14 overlooking the airport apron. The original is 3264×2448 with 64 EXIF fields, a 160×120 thumbnail and GPS at 40°29′27.76″N. Cropping the left half and writing the EXIF back produces a 1632×2448 image whose thumbnail is the uncropped view, including the Emirates aircraft the crop had removed. Same 64 fields, same coordinates.&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%2Fp74itwjlojuzng1owh7n.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%2Fp74itwjlojuzng1owh7n.png" alt="Left: the saved crop. Right: the thumbnail embedded in the same file's EXIF, enlarged. Top: my synthetic sample; bottom: CC0 iPhone 6 photo from Wikimedia Commons" width="799" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For contrast, Pillow's default &lt;code&gt;save()&lt;/code&gt; without &lt;code&gt;exif=&lt;/code&gt; writes none of it: zero EXIF fields, no GPS, no thumbnail. macOS &lt;code&gt;sips&lt;/code&gt; went a third way on my sample, cropping away the thumbnail but keeping the GPS. So neither "keep" nor "drop" is a default you can rely on across tools, and the only thing I trust is reading the output file back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it field by field
&lt;/h2&gt;

&lt;p&gt;The script keeps the EXIF but rewrites everything that describes the pixels. It rotates the pixels with &lt;code&gt;ImageOps.exif_transpose&lt;/code&gt;, crops, sets Orientation to 1, writes the new dimensions, rebuilds the IFD1 thumbnail from the cropped pixels when the source had one, and drops the GPS IFD unless asked to keep it. The ICC profile goes through as its own argument, because &lt;code&gt;exif=&lt;/code&gt; doesn't carry it; an earlier run on the sideways sample lost its profile exactly that way. The part where order matters is the top of the function (excerpt):&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;im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exif_transpose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# pixels upright
&lt;/span&gt;    &lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;exif&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;piexif&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImageIFD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orientation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest is short. The two PixelDimension tags in the Exif IFD get the cropped image's width and height, read from &lt;code&gt;im&lt;/code&gt; after the crop, so they describe the output rather than the source. Dropping GPS means emptying the GPS dict and popping the GPSTag pointer from IFD0. Rebuilding the thumbnail means copying the cropped image, shrinking it to fit 160 pixels, saving it as a quality-75 JPEG into a buffer and putting those bytes back in &lt;code&gt;exif["thumbnail"]&lt;/code&gt;, but only when the source had a thumbnail in the first place. Then &lt;code&gt;piexif.dump&lt;/code&gt; produces the new block for &lt;code&gt;save&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I ran it (Python 3.9, Pillow 11.3, piexif 1.1.3) on three inputs: the house-number sample, the Madrid photo, and a second synthetic photo stored sideways with Orientation 6, 4032×3024 and no thumbnail, and read every output back. The house sample came out at 2000×3000 with matching PixelDimension, a 107×160 thumbnail and no GPS. The Madrid crop came out at 1632×2448, again with matching dimensions, a 107×160 thumbnail and no GPS. The rotated photo came out at 3024×2016 with Orientation 1, PixelDimension 3024×2016, still no thumbnail, and no GPS. For reference, the naive crop-and-copy versions of the first two files kept PixelDimension at 4000×3000 and 3264×2448, the 160×120 thumbnail and the GPS.&lt;/p&gt;

&lt;p&gt;The rotated case is the one worth staring at. The crop box &lt;code&gt;(0, 0, 3024, 2016)&lt;/code&gt; is expressed in upright coordinates, which only works because the transpose happens before the crop. I ran it the other way round as a check: the same box applied to the stored, sideways pixels and then rotated gives 2016×3024, a portrait strip from a different part of the frame, while transpose-then-crop gives the 3024×2016 you'd expect. I rebuild the thumbnail rather than deleting it because some viewers may use it for quick previews; deleting it is just as valid and simpler. The 160-pixel bound only copies what both originals had. The Madrid file also went through &lt;code&gt;piexif.dump&lt;/code&gt; with its MakerNote without an error, which I had half expected to fail, since MakerNote is vendor-private and can contain offsets that break when the block moves. I haven't checked that the rewritten MakerNote still parses in Apple's own software, so count that part as unverified.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the script doesn't touch
&lt;/h2&gt;

&lt;p&gt;XMP is a separate packet, and the script never looks at it. That matters: when I converted the synthetic sample to PNG with &lt;code&gt;sips&lt;/code&gt;, it wrote the GPS into XMP as well as EXIF, so clearing the GPS IFD would have left a second copy behind. Pillow on my machine also can't open HEIC at all, so none of this covers HEIC sources without another decoder. The rule I've settled on is to split EXIF fields into "about the scene" (camera, exposure, capture time), which are safe to copy, and "about these bytes" (orientation, dimensions, thumbnail), which get recomputed or dropped every time the pixels change. GPS fits neither group. It should be a decision someone made on purpose, and the naive copies above show what happens when nobody does.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A scanned PDF with copyable text can still give you the wrong words</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:01:07 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-scanned-pdf-with-copyable-text-can-still-give-you-the-wrong-words-83f</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-scanned-pdf-with-copyable-text-can-still-give-you-the-wrong-words-83f</guid>
      <description>&lt;p&gt;A lot of document pipelines share one shortcut: call &lt;code&gt;extract_text()&lt;/code&gt;, and only fall back to OCR when it comes back empty. Archive scans rarely come back empty: Internet Archive runs tesseract over them and stores the result as an invisible text layer, so the shortcut treats a book printed in 1734 like a born-digital file. To see what that costs, I took two public-domain books from archive.org, transcribed one printed page of each by hand, and compared. The two books landed on opposite sides, which turned out to be the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the invisible text comes from
&lt;/h2&gt;

&lt;p&gt;PDF text has a rendering mode, set with the &lt;code&gt;Tr&lt;/code&gt; operator. Mode 3 means neither fill nor stroke: the glyphs have positions, you can select and copy them, and nothing gets painted. An OCR'd scan is a page image with the recognised words drawn on top in mode 3. The viewer shows you the image, &lt;code&gt;extract_text()&lt;/code&gt; hands you the words, and nothing in the file checks that the two agree. To spot such pages I walk the content stream with pypdf, track the current &lt;code&gt;Tr&lt;/code&gt; value, and count text-showing operators (&lt;code&gt;Tj&lt;/code&gt;, &lt;code&gt;TJ&lt;/code&gt;, &lt;code&gt;'&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;) under mode 3 versus everything else. &lt;code&gt;Tr&lt;/code&gt; is part of the graphics state, so it gets saved by &lt;code&gt;q&lt;/code&gt; and restored by &lt;code&gt;Q&lt;/code&gt;; without a small stack for that, a mode change inside a nested block leaks out and the counts drift. While I'm on the page, I also list each image XObject's &lt;code&gt;/Filter&lt;/code&gt; and the filter of its &lt;code&gt;/SMask&lt;/code&gt;, if it has one.&lt;/p&gt;

&lt;p&gt;On the two pages I cut from each book, the counts came out as 461 and 470 invisible text operators for 1921, 296 and 302 for 1734, and zero visible ones anywhere. Every text operator on all four pages is invisible. Each page also carries two images: a JPEG 2000 layer (&lt;code&gt;/JPXDecode&lt;/code&gt;), and a second JPEG 2000 layer with a JBIG2 soft mask (&lt;code&gt;/JBIG2Decode&lt;/code&gt;). That's a mixed raster setup, a smooth background plus a 1-bit mask that keeps glyph edges sharp. A born-digital PDF usually looks the other way round, with no mode-3 text at all, so the ratio separates the two kinds of file cleanly. I spent three years writing codecs at an SDK company, and I still admire how small these pages get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two books, opposite results
&lt;/h2&gt;

&lt;p&gt;Book one is &lt;em&gt;Psycho-Analysis and the War Neuroses&lt;/em&gt; (London, 1921), text layer from tesseract 5.3.0. On printed page 2 (460 words, 2,706 characters, transcription checked line by line against a 3489-pixel-high render), the text layer is off by 4 character edits, 0.15%, with errors like &lt;code&gt;whichis&lt;/code&gt; and a stray &lt;code&gt;|&lt;/code&gt; at a line end. I also rendered the page and ran OCR on the pixels again: 7 edits, 0.26%, including &lt;code&gt;aro&lt;/code&gt; for "are" and &lt;code&gt;renderedl&lt;/code&gt;. Re-recognising did not beat the layer that was already there.&lt;/p&gt;

&lt;p&gt;Book two is &lt;em&gt;Round About Our Coal Fire&lt;/em&gt; (4th edition, 1734), tesseract 4.1.1: long s, italics, a &lt;code&gt;ct&lt;/code&gt; ligature, catchwords. On page (3), 301 words and 1,577 characters, the text layer comes to 2.2% if I don't count ſ read as f, 4.1% if I do. It gives you &lt;code&gt;Loudon&lt;/code&gt; for London, &lt;code&gt;Bithop ot Wincheffer's&lt;/code&gt; for Biſhop of Wincheſter's and &lt;code&gt;Ele@tiion&lt;/code&gt; for Election. A rendered re-OCR brings the page to 1.0% (3.2% strict). Neither pass knows the long s; both read it as f.&lt;/p&gt;

&lt;p&gt;So the result is not "never trust a text layer". A good tesseract layer on a clean 20th-century book was the best text I had. The old typeface is what broke it, and you only find that out by looking at a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a browser extractor did with them
&lt;/h2&gt;

&lt;p&gt;I also ran both files through Extract PDF content in ImgIng, a tool I help build. My part is codecs and model loading; the PDF engine is someone else's work. With either book, the source preview was a blank page, and the TXT export with "Recognize text inside images" switched on was byte-identical to the export with it switched off. No Image OCR block, no warning. That fits the page images never being decoded: Chromium ships no JPEG 2000 or JBIG2 decoder, so a web page has to bring its own, and when that step produces no pixels, OCR has nothing to read and only the embedded layer is left. That's my inference from the codec side; I haven't traced it through the engine.&lt;/p&gt;

&lt;p&gt;For the 1921 book the fallback is harmless. For the 1734 book it did real damage. The output lost 12 words that pypdf finds in the layer (&lt;code&gt;Countenances;&lt;/code&gt;, &lt;code&gt;the honeft&lt;/code&gt;, &lt;code&gt;at Chelfea;&lt;/code&gt; and others, mostly at the start of a line) and moved fragments to the wrong line, which put page (3) at 19.5% against the layer's own 2.2%. I haven't found the cause yet. It also cut justified lines with wide word gaps into two-cell tables: 48 of the 109 lines on the two 1921 pages carry a Tab in the TXT.&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/..." 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/..." alt="1734 scan in the English Extract PDF content workspace: blank source preview on the left, text-layer errors such as " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Render, re-OCR, compare one page
&lt;/h2&gt;

&lt;p&gt;The workaround is to render the pages with something that decodes both formats, save them as an image-only PDF, and OCR that. My measured run used macOS &lt;code&gt;sips&lt;/code&gt; for the render. On Linux, PyMuPDF decodes JPEG 2000 and JBIG2, and in the version I checked, the only line that needs any thought is the resolution (excerpt):&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;widest&lt;/span&gt; &lt;span class="o"&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&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;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_images&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;dpi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;widest&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That dpi goes straight into &lt;code&gt;get_pixmap&lt;/code&gt;, each rendered page then goes to PIL and the list is saved as one image-only PDF. For the 1734 file this comes out at 72 dpi, 1483×2746 for the first page and 1522×2839 for the second. The "dpi" just maps one scan pixel to one output pixel (the 1921 file comes out at 400). These renders decode fine and carry no text layer, but the recognition numbers above came from the &lt;code&gt;sips&lt;/code&gt; render. Fed to the same extractor, an image-only PDF gets a normal preview and an Image OCR block:&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%2F3nofaqjkckpdz4ti2g7e.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%2F3nofaqjkckpdz4ti2g7e.png" alt="The same 1734 page after rendering to an image-only PDF: the source preview shows the page, the result is marked Image OCR, and " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You don't need a hand transcription to decide. Comparing the embedded layer with a re-OCR of the same page gets you most of the way. I split both texts into words, after folding every quote variant into one and joining words hyphenated across a line break, then let &lt;code&gt;difflib&lt;/code&gt; find the matching blocks (excerpt):&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[“”‘’&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;`\"]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-\s*\n\s*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# join end-of-line hyphenation
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]+|\d+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ocr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autojunk&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;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_matching_blocks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agreement is &lt;code&gt;same&lt;/code&gt; divided by the longer of the two word lists, and the non-equal opcodes give you the disagreements side by side. On the 1921 page the layer has 463 words, the re-OCR 462, and they agree on 99.1%; the differences are a lone &lt;code&gt;2&lt;/code&gt; the re-OCR dropped, a quote mark, &lt;code&gt;are&lt;/code&gt; against &lt;code&gt;aro&lt;/code&gt; and &lt;code&gt;rendered&lt;/code&gt; against &lt;code&gt;renderedl&lt;/code&gt;. On the 1734 page it's 302 words against 299 and 88.4%, with pairs like &lt;code&gt;weicome&lt;/code&gt; / &lt;code&gt;welcome&lt;/code&gt;, &lt;code&gt;Hull&lt;/code&gt; / &lt;code&gt;Hall&lt;/code&gt; and &lt;code&gt;greafy&lt;/code&gt; / &lt;code&gt;grcafy&lt;/code&gt; running down the list. The gap between the two books is wide, and the 1734 list reads like an index of the layer's mistakes. The check has a blind spot: &lt;code&gt;whichis&lt;/code&gt; is wrong in both versions, so it never appears. Two readers agreeing is not the same as both being right.&lt;/p&gt;

&lt;p&gt;What I do now is sample one page per source. If the layer and the re-OCR mostly agree, keep the layer; it's cheaper, and on the 1921 book it was the better text. If they disagree word by word, re-render and re-OCR the whole book. Two books aren't enough to put a number on "mostly"; 99 against 88 was a wide enough gap that I didn't need one. ImgIng is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Pick the encoding route before the tool: PNG quantisation, WebP and AVIF across five compressors</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:01:10 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/pick-the-encoding-route-before-the-tool-png-quantisation-webp-and-avif-across-five-compressors-8pi</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/pick-the-encoding-route-before-the-tool-png-quantisation-webp-and-avif-across-five-compressors-8pi</guid>
      <description>&lt;p&gt;![ ](&lt;a href="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/oWhen" rel="noopener noreferrer"&gt;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/oWhen&lt;/a&gt; a team asks me which image compressor to standardise on, I usually push back on the question. A compressor is a bundle of encoding routes wrapped in a delivery model, and the routes are where the engineering decisions live. Two tools that both say "PNG supported" may be doing completely different things to the file. So this piece lays out the routes first and then places five tools on them, because once the route is chosen, the tool choice mostly falls out.&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/..." 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/..." alt="Five image-compression tools compared (from public product info)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Route one is lossless PNG optimisation versus lossy colour reduction. Lossless tools reorder filters and squeeze the deflate stream, and the gains are modest. Colour reduction to an 8-bit palette with dithering is where PNG gets dramatically smaller, at the cost of being lossy. pngquant is the reference implementation most pipelines rely on. TinyPNG's PNG path is built on this kind of quantisation and is the reason it became famous. ImgIng at imging.ai ships its own quantiser running in the browser; on one specific reference sample reduced to 256 colours it measured 45.8 dB PSNR, which I cite with the boundary intact: a single sample, not evidence of a general win over pngquant, and I would expect the two to trade results by image.&lt;/p&gt;

&lt;p&gt;Route two is JPEG re-encoding. Here the field has converged on MozJPEG-class output, trellis quantisation and progressive scans, and the differences between tools are small enough that the quality setting dominates. ImgIng lands at MozJPEG-level output; Squoosh exposes MozJPEG directly with every knob visible. The server tools produce similar files. This is the route where I tell teams not to overthink the vendor.&lt;/p&gt;

&lt;p&gt;Route three is modern codecs, WebP and AVIF. WebP gives a reliable 25 to 35 percent over JPEG at similar perceived quality and is safe to ship broadly. AVIF goes further but costs encoder time, and the encoder that matters is libavif. Squoosh's AVIF path is the well-known browser implementation and its side-by-side viewer is the right place to study the trade-off on a single image. ImgIng produces AVIF with libavif on the client as well, so I treat the two as sharing a lineage rather than competing on ratio; a small test icon came out at 913 bytes, which says more about AVIF than about either tool. I did not benchmark the server tools on AVIF for this note; compressor.io's distinguishing coverage is SVG rather than a raster codec.&lt;/p&gt;

&lt;p&gt;Route four is animation. GIF, APNG and animated WebP need inter-frame differencing to shrink meaningfully, because most frames repeat most of the previous frame. This is a separate engineering effort from still-image encoding, and it is the route with the fewest occupants. ImgIng handles all three with frame differencing; Squoosh does not open animated files; the upload services in this set do not advertise it, and I did not benchmark them on it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;TinyPNG&lt;/th&gt;
&lt;th&gt;compressor.io&lt;/th&gt;
&lt;th&gt;docsmall&lt;/th&gt;
&lt;th&gt;Squoosh&lt;/th&gt;
&lt;th&gt;ImgIng&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PNG colour reduction&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;own quantiser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG (MozJPEG class)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF (libavif)&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes, client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animated GIF/APNG/WebP&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the delivery model, which is the second axis. Server-side execution means unattended throughput and a vendor SLA; TinyPNG's API with its 500-image monthly free allowance and 5 MB cap is the sensible choice for runtime ingestion, and docsmall covers batch plus PDF for Chinese-speaking teams on a 5 MB free ceiling with membership above that. compressor.io's 10 MB free ceiling and SVG support make it the one-off large-file tool. Browser-side execution means the compute is the user's, so limits disappear: Squoosh is free without quota, and ImgIng adds batch, no login, no watermark, and returns the original when a file would not shrink. The cost is that throughput is bounded by the client machine and that "local" is a per-format statement to verify, not a blanket guarantee.&lt;/p&gt;

&lt;p&gt;Performance, then, is not one number. For a runtime pipeline the metric is wall-clock per image on a warm server, and the server tools win because that is what they are. For a human-driven batch the metric is time from folder in to folder out, and a local batch tool wins because there is no upload leg and no queue. For a single asset the metric is how quickly a human converges on the right setting, and Squoosh's viewer wins that comparison for me.&lt;/p&gt;

&lt;p&gt;The recommendation I actually write into architecture notes is a pairing rather than a pick: a server-side API for unattended ingestion, a browser-side batch tool for everything humans touch, and Squoosh on the side for studying a codec on one image. Choose the routes you need first; the tool list shrinks by itself.&lt;br&gt;
z4heogdm4xp65vyy60s.png)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My filter preview looked perfect. The export had seams.</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Thu, 10 Sep 2026 08:01:05 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/my-filter-preview-looked-perfect-the-export-had-seams-3ej0</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/my-filter-preview-looked-perfect-the-export-had-seams-3ej0</guid>
      <description>&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%2Fyzw5hop1tucmtkhcm0kb.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%2Fyzw5hop1tucmtkhcm0kb.png" alt=" " width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The preview looked flawless. A soft vintage filter across a 6000px poster, edges clean, the gradient smooth all the way down. Then I exported, opened the PNG at 100%, and there were faint vertical seams running through the image — thin bands where the tone shifted by a hair. The preview had never shown them. That gap between what you see and what you get turns out to be a consequence of how a browser has to do this, not a defect you can file a ticket against.&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/..." 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/..." alt="Online Design workspace" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A canvas editor running in the browser faces a hard limit a desktop app mostly ignores: memory. Online Design (imging.ai) lets you build up to 8192px on a side, roughly 48 million pixels, across as many as 24 layers, all of it client-side with nothing uploaded. Holding every layer at full resolution while you drag a slider would stall the tab, so the preview you interact with is deliberately rendered at a reduced resolution that fits your viewport, not the full canvas.&lt;/p&gt;

&lt;p&gt;When you nudge a filter — one of 12 presets or the 18 fine adjustments — it runs against that downscaled buffer. It is fast and it looks correct, because at screen size the output is visually identical to the full-resolution version. The moment you export, the tool throws the preview away and recomposes the filter against the original full canvas. Different pixel grid, different sampling, and any artifact that only exists at native resolution now has room to appear.&lt;/p&gt;

&lt;p&gt;So where do the seams come from? At full resolution a single 48-megapixel filter pass can exceed what the browser will allocate in one block. High-resolution filtering is therefore done in strips: the canvas is sliced into horizontal bands, each processed and written back in turn. If each band is filtered in complete isolation, a filter that samples neighbouring pixels — blur, sharpen, anything with a convolution kernel — has nothing to read at the band's edge, and the two sides of the cut end up computed from slightly different neighbourhoods. That mismatch along the cut is the seam.&lt;/p&gt;

&lt;p&gt;The fix is to give each strip an overlap. Every band is expanded by a margin on the shared edge, filtered with that margin present so the kernel always has real neighbours to sample, and then the margin is trimmed before the band is stitched back into place. The seam disappears because the pixels along the cut were computed with the same surrounding context on both sides. The preview never needed any of this — it fit in one buffer — which is exactly why the artifact showed up only on export.&lt;/p&gt;

&lt;p&gt;If you want to reproduce and check this deliberately, the flow is short. Create a canvas at the size you actually intend to export, importing a preset shape or a custom dimension. Import your image as a layer, then apply the filter either to that single layer or to the whole canvas. Export as PNG or WebP, and open the result at 100% and, more importantly, at 400%. The preview is a proxy; the exported file is the truth. If they disagree, you are looking at a resolution or stitching issue, not a colour one.&lt;/p&gt;

&lt;p&gt;One thing that made this painless to debug: the edit is non-destructive. The filter is stored as parameters, the original pixels are never re-encoded, and the project saves to a .imging file you can reopen and re-tune. I dialled the same filter up and down a dozen times comparing exports without ever degrading the source, which is the only reason isolating the strip behaviour was quick rather than a day of re-importing.&lt;/p&gt;

&lt;p&gt;The lesson I took away is boring but reliable: judge a browser image tool by its export at full zoom, never by its preview. The preview is optimised to be fast and to look correct at screen size. Everything that only surfaces at native resolution — banding, seams, softening — lives in the gap between the two, and that gap is where the actual engineering has to happen.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Convert a PDF to HTML and back to PDF — why is it bigger</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Wed, 09 Sep 2026 08:01:12 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/convert-a-pdf-to-html-and-back-to-pdf-why-is-it-bigger-3a2j</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/convert-a-pdf-to-html-and-back-to-pdf-why-is-it-bigger-3a2j</guid>
      <description>&lt;p&gt;A counterintuitive one: take a PDF, convert it to a webpage (HTML), then convert that webpage back to PDF — and the final PDF is often bigger than the original source. The reflex is "the conversion bloated it", but unpacking it, this is a structural fact of fixed-layout conversion, not a mistake.&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/..." 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/..." alt="PDF to HTML workspace" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, what "PDF to HTML" does. To make the webpage both &lt;strong&gt;look identical to the original PDF&lt;/strong&gt; and &lt;strong&gt;let you select and copy text&lt;/strong&gt;, it keeps two sets of things: a &lt;strong&gt;visible graphics layer&lt;/strong&gt; — text, paths and images rebuilt as vector outlines (SVG) by PDF coordinates, which is what your eye sees; and a &lt;strong&gt;transparent selectable-text layer&lt;/strong&gt; — sitting on top, invisible, but selectable by mouse and hit by search. What you see is the vector strokes; what you select is that transparent text. The two aligned and stacked give you "looks like an image, yet selectable and searchable".&lt;/p&gt;

&lt;p&gt;That's where it comes from. In the source PDF, a run of text usually has &lt;strong&gt;one&lt;/strong&gt; representation (glyphs rendered by font). Fixed-layout HTML, to serve both "faithful" and "selectable", splits it into &lt;strong&gt;two&lt;/strong&gt; — vector outline + transparent text layer. When you convert that HTML back to PDF, the converter faces two sets that both must be preserved, so the re-packaged PDF carries both. &lt;strong&gt;One set became two; bigger than a single-set source is what the structure dictates, not a conversion error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To be precise: the layout and generation of the PDF on the way back is done by the browser-grade layout and PDF-generation capability (Chromium / Skia), not some tool's in-house layout engine — the tool decides what information, in what structure, to feed it, plus post-generation optimisation. So the answer to "why bigger" isn't in the generation engine, it's that &lt;strong&gt;what was fed in was two sets to begin with&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Could you just drop one layer to win the size back? ImgIng's choice is &lt;strong&gt;not to&lt;/strong&gt;. It states it plainly: fixed-layout HTML keeps both the visible SVG outline and the transparent selectable-text layer, Chromium re-packages both on the way back, and it &lt;strong&gt;won't drop a layer, rasterise the vectors, or swap fonts just to match the source's byte count&lt;/strong&gt;. The reasoning is concrete — drop the transparent text layer and the PDF can't be selected or searched; flatten vectors to an image and it blurs on zoom and still isn't selectable. Each layer carries one ability you wanted (fidelity + selectability); sacrificing either for a nicer number trades a feature for bytes.&lt;/p&gt;

&lt;p&gt;So read the "bigger" correctly: it's the &lt;strong&gt;structural cost of keeping both "looks like the original" and "text is selectable and searchable"&lt;/strong&gt;, not waste. If you genuinely only want a viewable fixed layout and don't care about selecting text, don't take the "keep the selectable layer" path in the first place; but as long as you want "faithful and selectable", a round-trip growing in size is inevitable. Understand the two layers, and you stop suspecting the tool — the extra bytes are exactly the "selectable and searchable" you asked for. Tool is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why does Word's page count change when you convert it?</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:01:20 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-does-words-page-count-change-when-you-convert-it-1cmb</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/why-does-words-page-count-change-when-you-convert-it-1cmb</guid>
      <description>&lt;p&gt;The same Word document opens as 5 pages on your machine and 6 on a colleague's; convert it to PDF and it's different again; convert to a webpage and pagination shifts once more. The reflex is "the file is broken" or "the tool is bad" — but really it's treating pagination as something stored in the file. It isn't.&lt;/p&gt;

&lt;p&gt;Word's pagination is &lt;strong&gt;computed, not stored&lt;/strong&gt;. What the file holds is content and rules — text, styles, paragraphs, "this run is this font at this size with this line spacing". How that content flows into pages, and where each page breaks, is worked out &lt;strong&gt;at layout time by whatever program opens it&lt;/strong&gt;. That computation depends on three things: &lt;strong&gt;font metrics&lt;/strong&gt; (the same font name can have slightly different glyph widths across systems), &lt;strong&gt;printer metrics&lt;/strong&gt; (Word historically references the default printer's page parameters to align with print output), and &lt;strong&gt;compatibility mode&lt;/strong&gt; (which Word version's layout rules the document declares). Change any of these and the same content paginates differently.&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/..." 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/..." alt="DOCX to HTML operation page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The crucial part: &lt;strong&gt;OOXML doesn't reliably store "where the final page break landed"&lt;/strong&gt;. It stores the ingredients that produce pagination (content + rules), not the pagination itself. Some documents carry break hints, some don't. So a conversion tool holding a docx faces this reality: I have the content and the rules, but "which line the original author's machine broke on" may simply not be in the file.&lt;/p&gt;

&lt;p&gt;That decides how honest a tool can be. ImgIng's docx-to-html rebuilds fixed layout by coordinates, but on pagination its choice is: &lt;strong&gt;when there's no reliable page-break marker, it prompts you to verify the page endings&lt;/strong&gt; rather than hard-coding a break that looks right but may be wrong. I agree with that call — the alternative (pretending its computed pagination is authoritative) is passing off an uncertain guess as a fact. In document conversion, flagging "I'm not sure" beats emitting a pretty but wrong page number.&lt;/p&gt;

&lt;p&gt;So it doesn't promise "output paginates identically to your Word". That's not a shortcoming — the thing has no single right answer. Your Word pagination is just one result under "your machine, your fonts, your compatibility mode"; another environment recomputes it. Chasing "identical pagination everywhere" is a false goal.&lt;/p&gt;

&lt;p&gt;A usable takeaway for anyone doing document processing: &lt;strong&gt;if your case strictly depends on "pagination must match a specific machine"&lt;/strong&gt; — a contract citing page N clause M, layout with legal meaning — then any cross-environment conversion (to PDF, to web, opening on another PC) is a risk; keep the original and verify page endings by hand. &lt;strong&gt;If you just need a cross-device-openable, layout-faithful reading copy&lt;/strong&gt;, converting to a single HTML file is actually steadier than sending the Word — because it freezes the layout instead of recomputing it on every machine. Tool is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Same .mp4, one works and one doesn't — container vs codec</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:01:06 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/same-mp4-one-works-and-one-doesnt-container-vs-codec-ig5</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/same-mp4-one-works-and-one-doesnt-container-vs-codec-ig5</guid>
      <description>&lt;p&gt;Two &lt;code&gt;.mp4&lt;/code&gt; files, one processes fine and the other throws "unsupported" the moment it's imported. First time it looks like a tool bug; it's actually treating "container" and "codec" as the same thing. They're separate layers, and the extension only names the outer one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.mp4&lt;/code&gt;, &lt;code&gt;.mov&lt;/code&gt;, &lt;code&gt;.webm&lt;/code&gt;, &lt;code&gt;.mkv&lt;/code&gt; name the &lt;strong&gt;container&lt;/strong&gt; — the box holding the audio and video tracks. What codec the actual picture data inside is compressed with is a different matter. The same MP4 box can hold H.264, or H.265, or AV1; a WebM can hold VP9 or AV1. The suffix tells you the box shape, not whether what's inside is something the current environment can open.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container (suffix)&lt;/th&gt;
&lt;th&gt;Possible video codecs inside&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;.mp4 / .mov&lt;/td&gt;
&lt;td&gt;H.264, H.265 (HEVC), AV1…&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.webm&lt;/td&gt;
&lt;td&gt;VP8, VP9, AV1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.mkv&lt;/td&gt;
&lt;td&gt;almost any combination&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So "can it be processed" was never a question about the suffix — it's about the real codec of the track inside, and whether the current browser can decode that codec and encode the one you want out. Browsers decoding H.264 is common; H.265, not necessarily; encoding H.264, but maybe not AV1. These are independent, and if any one fails, that ".mp4" simply can't be processed on that device.&lt;/p&gt;

&lt;p&gt;ImgIng's approach in video compression is to not trust the suffix: on import it checks the real audio/video track codec inside the container. It says so directly — MP4, MOV, WebM, MKV are just containers, the inside may be a codec the current browser doesn't support. That step isn't excess caution, it avoids "the UI says imported, then it fails halfway through encoding". Find out what's inside and whether the device can take it, then decide whether to let you process it.&lt;/p&gt;

&lt;p&gt;AV1 is the textbook case. High compression, but heavy to encode and narrowly supported, so ImgIng only opens it &lt;strong&gt;after a runtime capability check passes&lt;/strong&gt; — not guessing "this browser probably supports it" from the UA, but actually probing whether the current environment can encode AV1, and only then offering it. That's a different world from "the browser claims to support a format and then can't produce it": one is a UI promise that fails at execution, the other confirms before executing.&lt;/p&gt;

&lt;p&gt;My boundary here: the video codec itself goes through the browser's WebCodecs, not an engine I built; my layer handles the import-time track check and capability-probe scheduling. From there the judgement is: when "same MP4, one works one doesn't" happens, don't suspect the tool — go look at the real video codec in the two files. Usually one is H.264 and the other H.265 or something else, and the device only decodes the former.&lt;/p&gt;

&lt;p&gt;So, one line: &lt;strong&gt;the suffix is the box, the codec is the contents, and processability is decided by contents and device together.&lt;/strong&gt; To tell whether a video can be processed by some tool, don't look at whether it's called &lt;code&gt;.mp4&lt;/code&gt; or &lt;code&gt;.webm&lt;/code&gt;, look at the real track codec inside, then whether this device can decode and encode it. One more hard condition: WebCodecs local transcoding needs an HTTPS or localhost secure context — without it, none of the above even applies. Tool is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What does 20x more OCR model size actually buy you?</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:00:53 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/what-does-20x-more-ocr-model-size-actually-buy-you-4nj</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/what-does-20x-more-ocr-model-size-actually-buy-you-4nj</guid>
      <description>&lt;p&gt;Installing OCR, three model tiers side by side, the reflex is "pick the biggest, it's most accurate". Reading what the tiers actually say, the 20x size gap doesn't buy one single thing.&lt;/p&gt;

&lt;p&gt;ImgIng uses PP-OCRv6 in three tiers. Here's what separates them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Languages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;~6.0 MB&lt;/td&gt;
&lt;td&gt;Chinese, English + Latin scripts (de/es/pt/fr)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro (default)&lt;/td&gt;
&lt;td&gt;~31.2 MB&lt;/td&gt;
&lt;td&gt;above + Japanese&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultra&lt;/td&gt;
&lt;td&gt;~138.8 MB&lt;/td&gt;
&lt;td&gt;above + Japanese&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the language column first. Fast reads Chinese, English and the Latin scripts; Pro and Ultra add Japanese. So going 6 MB → 31 MB, a big chunk of those extra 25 MB is "the whole Japanese character set and its recognition" — thousands of kanji plus two kana syllabaries, a whole class of characters to learn, and the size can't stay small.&lt;/p&gt;

&lt;p&gt;Now 31 MB → 138 MB. The languages don't grow, it's the same set. That extra hundred-plus MB buys accuracy on harder samples in those same languages — Ultra's positioning says it plainly: desktop, complex, small text. The same Chinese line, printed large and clean, all three probably get right; shrink it to a blurry small-font table screenshot and the gap opens. So this segment isn't "reads more characters", it's "gets the hard-to-read characters right".&lt;/p&gt;

&lt;p&gt;Which gives the rule: &lt;strong&gt;size isn't a monotonic accuracy dial — it buys language coverage first, then hard-case accuracy.&lt;/strong&gt; Answer "what languages" before "how hard is the image". Chinese and English only, image clean? The 6 MB tier is enough, no reason to drag down 138 MB.&lt;/p&gt;

&lt;p&gt;I ran one for real: a dense Chinese UI screenshot, Pro tier, recognised 69 lines / 605 characters, about 20.9 seconds on desktop Chromium, WebGPU backend.&lt;/p&gt;

&lt;p&gt;On boundaries, from the layer I actually work on — the OCR model itself isn't mine, PP-OCRv6 is integrated; I handle its download-verification and Worker scheduling. From there: all three tiers use a fixed model name, size and SHA-256, and won't silently swap at runtime. That's a hard guard against "you think you loaded Ultra, you actually got something else". A few tens to hundreds of MB, one bad chunk mid-download and the output is garbage — better to block it.&lt;/p&gt;

&lt;p&gt;Two more boundaries worth knowing before you pick a tier. One, &lt;strong&gt;the current unified model doesn't read Korean&lt;/strong&gt; — a Korean UI says so honestly; don't expect a bigger tier to fix it, that's coverage, not precision. Two, handwriting, the semantics of math formulas, and arbitrarily complex table relationships are outside the current version's scope — not "the tier's too small", but the boundary of OCR itself. So the order is reversed from instinct: not size first, but whether your language is in this tier's coverage, then how hard the image is. On mobile you don't even choose — only the fast tier is exposed, to keep WeChat or Safari from being killed by memory pressure. Tool is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Upscaling can't recover what downscaling deleted — I measured how much</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 05 Sep 2026 08:01:01 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/upscaling-cant-recover-what-downscaling-deleted-i-measured-how-much-5g8</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/upscaling-cant-recover-what-downscaling-deleted-i-measured-how-much-5g8</guid>
      <description>&lt;p&gt;Here's an experiment you can reproduce. Take an image with fine structure — thin gridlines, text, sharp edges — shrink it to a quarter of its side length, interpolate it back up to the original size with bicubic, and compare against the original pixel by pixel.&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/..." 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/..." alt="Shrunk to 1/4 then upscaled back — the text goes soft" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I measured two things: PSNR, and high-frequency energy (the mean absolute difference between neighbouring pixels — how much edge and texture survives). At 4x, PSNR was 17.4 dB and high-frequency energy dropped to 20.5% of the original. At 2x it was better, around 52%. Desktop Chromium, one image, so the numbers are only about this picture — but the trend is steady: shrink then enlarge, and most of the edges don't come back.&lt;/p&gt;

&lt;p&gt;The reason isn't complicated. When the image was a quarter of the size, the high-frequency information carried by those thin lines and sharp letter edges simply didn't exist in the low-resolution pixels anymore. Interpolation fills the gaps between existing pixels with a mathematical formula. It can make an edge look smooth, but it cannot conjure back the line that was there. &lt;strong&gt;The information isn't in the image, so interpolation can't produce it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Super-resolution takes a different route
&lt;/h2&gt;

&lt;p&gt;So how does a model recover it? It doesn't interpolate — it guesses, from having seen a lot of real photographs. Models like Real-ESRGAN, SwinIR and the official Real-HAT-GAN are trained on pairs of clean and degraded images. They learn what kind of clean structure usually sits behind this blur, this noise, this compression artefact. At inference they don't smooth, they fill in the likely structure. Same enlargement, opposite mechanism: interpolation spreads existing pixels, super-resolution paints in new ones — and the part it paints is exactly the ~80% interpolation lost.&lt;/p&gt;

&lt;p&gt;To make that concrete, here's one real run on ImgIng (imging.ai): a 350×466 faded old photo, "old-photo restoration" tier at 4x, finished locally in &lt;strong&gt;9.9 seconds&lt;/strong&gt; on WebGPU, output 1400×1864, using Real-ESRGAN x4plus. The image never left the machine — the panel labels the work "local Worker". I own the codec and model-loading side of this product, not the matting or the super-resolution model itself, so on how the model paints structure I'll stop at "it's learned".&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory constraint, and the line you can't cross
&lt;/h2&gt;

&lt;p&gt;From the loading side, one thing worth explaining is why a big image doesn't blow up memory. Pushing a several-thousand-pixel image through the model in one shot will crash the tab. So inference runs tiled inside a Worker: each tile is read with its own context window, only the reliably-predicted centre is written to the output, and the under-supported edges aren't blended back. Each tile releases its tensors as it finishes; the cached model on disk isn't the same as inference memory held open.&lt;/p&gt;

&lt;p&gt;The five tiers aren't a quality ladder, they differ in what they reconstruct: fast for preview; fidelity for text, buildings and lines; old-photo for overall tonal recovery; dual-model for continuous skin-and-background texture; and the high-end tier running Real-HAT-GAN Sharper with large-context tiling for natural texture on desktop. Pick by what your image lost, not by "which is strongest".&lt;/p&gt;

&lt;p&gt;And the line that matters most: &lt;strong&gt;what super-resolution adds is the model's guess, not detail the original held.&lt;/strong&gt; It's good for visual enhancement — the picture looks clearer. It is not for ID photos, forensic or historical verification, or OCR ground-truth, because those need "what the original actually was", and the model gives you "what it probably was". ImgIng's own panel says it plainly: it won't repaint facial features, and a face too low-res to read can't have real information that was never there restored. Those two things get conflated everywhere else; here they can't be.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The transparent checkerboard was hiding 5.73% of the frame</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:01:20 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/the-transparent-checkerboard-was-hiding-573-of-the-frame-4j3h</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/the-transparent-checkerboard-was-hiding-573-of-the-frame-4j3h</guid>
      <description>&lt;p&gt;A transparent checkerboard is a dishonest background. The same cutout looks spotless on it, and the moment you drop it on white, something from the original background fades back in. I ran into this while comparing three matting models, and my first guess was that I had written the compositing wrong.&lt;/p&gt;

&lt;p&gt;The test image was picked to be annoying. A hand holds a frosted glass bottle at an angle, the cap is mirror-finish metal, and behind it sits a pile of silver-white ornaments. Subject and background share a colour family, and the highlights and semi-transparent areas run into each other. There were four paths available: one non-AI "fast background removal", plus three AI tiers at roughly 42 MB, 219 MB and 447 MB. I wanted to know in what order you are supposed to choose between them.&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/..." 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/..." alt="Four background-removal results" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ten minutes of staring at the four results side by side and I could not rank them. My assumption that they would line up neatly by file size just did not survive contact with the output. I don't own the matting algorithm — the codec and model-loading layer is my part of this — so instead of guessing from the picture, I read the channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the checkerboard covers
&lt;/h2&gt;

&lt;p&gt;Here is the counter-intuitive one. In the heaviest tier's output, &lt;strong&gt;5.73% of the frame sits at alpha 1–8&lt;/strong&gt;. At that opacity it is invisible on a checkerboard. Put it on solid white and the outline of those background ornaments comes back.&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%2Fr11z7lex4zf2xjp5ipi4.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%2Fr11z7lex4zf2xjp5ipi4.png" alt="The same pixels on three backgrounds" width="800" height="1103"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full distribution across the four paths:&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;alpha = 0&lt;/th&gt;
&lt;th&gt;1–8&lt;/th&gt;
&lt;th&gt;9–127&lt;/th&gt;
&lt;th&gt;128–254&lt;/th&gt;
&lt;th&gt;255&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fast removal (non-AI)&lt;/td&gt;
&lt;td&gt;59.44%&lt;/td&gt;
&lt;td&gt;0.000%&lt;/td&gt;
&lt;td&gt;1.77%&lt;/td&gt;
&lt;td&gt;1.56%&lt;/td&gt;
&lt;td&gt;37.22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast · ISNet INT8&lt;/td&gt;
&lt;td&gt;75.66%&lt;/td&gt;
&lt;td&gt;0.72%&lt;/td&gt;
&lt;td&gt;0.45%&lt;/td&gt;
&lt;td&gt;2.04%&lt;/td&gt;
&lt;td&gt;21.13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro · BEN2 FP16&lt;/td&gt;
&lt;td&gt;68.31%&lt;/td&gt;
&lt;td&gt;0.89%&lt;/td&gt;
&lt;td&gt;0.41%&lt;/td&gt;
&lt;td&gt;10.99%&lt;/td&gt;
&lt;td&gt;19.41%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max · BiRefNet HR-Matting&lt;/td&gt;
&lt;td&gt;63.13%&lt;/td&gt;
&lt;td&gt;5.73%&lt;/td&gt;
&lt;td&gt;1.23%&lt;/td&gt;
&lt;td&gt;5.84%&lt;/td&gt;
&lt;td&gt;24.07%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One image, desktop Chromium. These numbers describe this photo and nothing else. Look at the second column: the non-AI path is a flat 0.000%, not a single pixel, because it isn't a model — it deletes background colour that is both similar and connected, starting from the canvas edge, so a pixel is either in or out with no middle state. All three models trail an alpha 1–8 tail, and the largest model's tail is nearly eight times the other two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven points of difference are a hand
&lt;/h2&gt;

&lt;p&gt;Pixels above alpha 10: 23.6% for the fast tier, 30.8% for pro, 31.1% for max. The fast tier's non-zero region is boxed inside x 270–790 and y 129–1341 — the left two hundred columns and the bottom ninety rows are completely empty. That is not a badly finished edge. The model decided the subject of this photo is the bottle and the hand is background. Sampling the hand region gives a mean alpha of 102, with a few fingernail outlines still hanging on.&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%2Ftmoys3b5kdgm9djxt063.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%2Ftmoys3b5kdgm9djxt063.png" alt="The hand region across the three tiers" width="799" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If what you wanted was a bottle on its own, that is exactly right. If "held in a hand" was the point of the shot, the model has redefined the subject rather than done a rough job on it. The pro tier keeps the hand and pays for it in the fourth column: 10.99% of pixels land in 128–254, and pulling them out shows almost all are 249–254, a very light soft alpha over the whole subject that looks opaque but isn't 255. The max tier gives the subject the highest share of hard 255 of the three, then parks its uncertainty as that faint 5.73% residue spread back across the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the order is backwards
&lt;/h2&gt;

&lt;p&gt;The three tiers don't differ in edge sharpness. They differ in where they put the parts they aren't sure about: the fast tier throws them away, the pro tier smears them over the subject, the max tier smears them over the background. Which means the first question isn't "which model is strongest", it's "what kind of mistake can this image afford".&lt;/p&gt;

&lt;p&gt;Uniform-colour backgrounds — packshots, flat-colour assets — should try the non-AI path first and skip the download entirely. Its failure mode is easy to spot: any disconnected chunk of background survives whole and you see it immediately. Simple subject, single object, clean background: 42 MB is enough, and it is the only tier that doesn't make a low-spec machine wait. Hands, hair, semi-transparent material — go up, but add a step after the biggest tier, because that residue only shows on a solid background and you'll ship it otherwise.&lt;/p&gt;

&lt;p&gt;One thing to fold into the choice: the models come down from the site and go into browser cache, inference runs locally, and the first time you pick the 447 MB tier you wait for all 447 MB. That cost is real even though it's a one-off. Don't max the slider out of habit. The tool I ran this on is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
