<?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: NullPointerZen</title>
    <description>The latest articles on DEV Community by NullPointerZen (@yue_shu_c621a4a637f22396f).</description>
    <link>https://dev.to/yue_shu_c621a4a637f22396f</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%2F4091461%2F67d2ab23-740e-4763-8279-b58904d35440.png</url>
      <title>DEV Community: NullPointerZen</title>
      <link>https://dev.to/yue_shu_c621a4a637f22396f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yue_shu_c621a4a637f22396f"/>
    <language>en</language>
    <item>
      <title>108 Tesseract runs on a skewed, noisy scan: the preprocessing order that held 98.5–99.5%</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:04:54 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/108-tesseract-runs-on-a-skewed-noisy-scan-the-preprocessing-order-that-held-985-995-54dj</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/108-tesseract-runs-on-a-skewed-noisy-scan-the-preprocessing-order-that-held-985-995-54dj</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%2Fshfya3bepr3mriwp8e78.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%2Fshfya3bepr3mriwp8e78.png" alt=" " width="800" height="384"&gt;&lt;/a&gt;&lt;br&gt;
The usual recipe for scanned PDFs is deskew, denoise, binarize. What I couldn't find was which step actually matters, or whether the order does. I came to this from content work, not CS, so I took one scanned page and built a grid. The page is page 8 of H. G. Wells' &lt;em&gt;The Time Machine&lt;/em&gt; (Heinemann, 1895), a public domain scan on Wikimedia Commons, 1,002 characters of ground truth. I rotated it by 0°, 1°, 3° and 5°, gave it three noise levels (none, Gaussian σ=25, salt-and-pepper 5%), and ran nine treatments on each, counting "do nothing" as one of them. That is 4 × 3 × 9 = 108 runs of Tesseract 5.5.3, one run each, PNG input, on a Mac with an Apple M4. Accuracy is 1 − edit distance / 1,002, so 99.0% means about 10 characters wrong, missing or extra. It is one clean single-column page, and the skew and noise came from a script, not a real scanner.&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="Heat map of Tesseract character accuracy for 108 runs: 9 preprocessing rows by 4 skew angles under clean, Gaussian and salt-and-pepper noise" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What to look at: the "Denoise → deskew → Otsu" row stays dark green across all twelve columns, while "Deskew only" turns red under salt-and-pepper noise and "Adaptive only" is 0.0 under Gaussian noise. Sample: page 8 of H. G. Wells, The Time Machine (1895), public domain scan via Wikimedia Commons; skew and noise are my own.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Noise hurt Tesseract far more than skew
&lt;/h2&gt;

&lt;p&gt;On the clean page with no preprocessing, accuracy went 98.9 at 0°, 99.5 at 1°, 98.1 at 3° and 96.2 at 5°. So 1° cost nothing, 3° cost 0.8 points and 5° cost 2.7 points, and deskewing alone pulled the 5° page back to 99.0. With Gaussian noise it went from 97.9 at 0° to 92.9 at 5°. Salt-and-pepper was the real damage: 75.5 with the page perfectly straight, 63.3 at 5°.&lt;/p&gt;
&lt;h2&gt;
  
  
  Deskewing a speckled page first made it worse
&lt;/h2&gt;

&lt;p&gt;With salt-and-pepper noise, "deskew only" scored 48.9, 42.2, 28.3 and 7.3 across the four angles, worse than doing nothing in every column. Deskew then denoise recovered to 91.5–94.4. Swapping the order, denoise then deskew, gave 98.6–99.1. My guess, which matches the intermediate images: the rotation uses cubic interpolation, which smears each isolated black or white pixel into a gray blob a few pixels wide. A 3×3 median can't remove blobs that size, and Tesseract reads them as strokes.&lt;/p&gt;

&lt;p&gt;Otsu on its own stayed within 1.2 points of doing nothing on the clean and Gaussian pages, probably because Tesseract binarizes internally (an inference). Adaptive thresholding (block 31, C=15) on the Gaussian page returned empty text at all four angles.&lt;/p&gt;
&lt;h2&gt;
  
  
  The order that held across all twelve conditions
&lt;/h2&gt;

&lt;p&gt;Denoise, then deskew, then Otsu scored between 98.5 (salt-and-pepper, 0°) and 99.5 (Gaussian, 5°) in all twelve columns. Denoise then deskew without Otsu was close behind at 98.2–99.1, so the order does most of the work. Below is a simplified sketch of that pipeline, rewritten from the grid's parameters for illustration. It is not the script that produced the numbers above (that one also enlarged the canvas so corners weren't cut), and I haven't re-run the grid with it. The denoiser follows the noise type: non-local means (h=15, template 7, search 21) for Gaussian noise, a 3×3 median otherwise. The angle comes from a projection profile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;skew_angle&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;small&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;small&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="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_BINARY_INV&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_OTSU&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRotationMatrix2D&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warpAffine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&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;axis&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;var&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;max&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;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean_page&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;gaussian_noise&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gaussian_noise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# 1. denoise first
&lt;/span&gt;        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fastNlMeansDenoising&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;medianBlur&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&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;shape&lt;/span&gt;   &lt;span class="c1"&gt;# 2. deskew, white fill
&lt;/span&gt;    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRotationMatrix2D&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;skew_angle&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="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warpAffine&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;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTER_CUBIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;borderValue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;threshold&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_BINARY&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_OTSU&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One red cell is misleading. "Denoise only" on the clean 5° page scored 34.0. The characters weren't misread. Tesseract's layout analysis chopped the single text column into several blocks and interleaved the sentences, and the same image deskewed first scored 98.9. I haven't worked out why the layout split there, so don't read it as "median filtering hurts". The filter does hurt once the kernel outgrows the type. A 9×9 median took the straight page from 98.9 to 91.0, and on a copy scaled to half size a 5×5 median already dropped it to 85.8. The 3×3 that worked here is tied to this page's resolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other recognizers wanted different steps
&lt;/h2&gt;

&lt;p&gt;I also checked two other engines on a Chinese page (557 characters, from the 1986 edition of the official Simplified Character list). macOS's built-in text recognition stayed at 4–12 wrong characters through 0–5° skew and Gaussian noise, deskewing did almost nothing, and Otsu made it slightly worse on the clean and Gaussian pages (salt-and-pepper was the one noise that clearly hurt it). The third engine was the PDF content extraction in ImgIng &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; , which runs a PP-OCR model inside the desktop browser. It was the most skew-sensitive: fine at 1°, lines out of order from 3°, and it lost two lines at 3° and roughly half the page at 5°, while the interface still showed the job as finished with no warning. After denoise and deskew the same 3° noisy Chinese page came back to 2 wrong characters. So the grid answers the question for Tesseract only.&lt;/p&gt;

&lt;p&gt;If I set this up again: denoise, deskew, then Otsu, with a kernel sized to the text, and before adding a step I run one page through my actual recognizer with and without it. I haven't tested real skewed scans, two-column layouts, phone photos or low-DPI pages, so this is about order of operations, not real-world accuracy.&lt;/p&gt;

&lt;p&gt;Sources: H. G. Wells, &lt;em&gt;The Time Machine&lt;/em&gt;, Heinemann 1895, p. 8, public domain scan via Wikimedia Commons, ground truth from English Wikisource (CC BY-SA 4.0); Chinese page from the 1986 Simplified Character list, Internet Archive, Public Domain Mark 1.0. Rotated and noisy versions are my own.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What Grad and Conn catch in an alpha matte that SAD misses, and what they don't</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:05:17 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/what-grad-and-conn-catch-in-an-alpha-matte-that-sad-misses-and-what-they-dont-27mb</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/what-grad-and-conn-catch-in-an-alpha-matte-that-sad-misses-and-what-they-dont-27mb</guid>
      <description>&lt;p&gt;When I score an alpha matte, SAD is the column I read first. This time I scored eight mattes against the same 90-frame ground truth and put SAD next to two other columns, Grad and Conn. Grad told me something SAD flattened out. Conn, on this data, mostly didn't, which was useful to learn too.&lt;/p&gt;

&lt;p&gt;Foreground and alpha came from a public-domain green-screen clip by VOA Learning English on Wikimedia Commons, 90 frames at 540×960 with a hand wave in the middle. I composited it onto a flat generated green and onto a busy generated background with &lt;code&gt;I = αF + (1−α)B&lt;/code&gt;, so every frame has a known alpha. Known is not the same as correct, though. The "true" alpha is the output of a larger matting model (RVM ResNet50), not a hand-refined matte, and the AI method under test is from the same model family, so its scores may lean optimistic.&lt;/p&gt;

&lt;p&gt;For the AI matte I used ImgIng &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; in its compatibility mode and pulled the alpha out of its transparent WebM export, once per background. The other six mattes are three FFmpeg chromakey settings, one hard green threshold and two controls made by altering the true alpha. Numbers below are 90-frame averages in the edge band (the true alpha's soft region plus about 7 px each side, 15.78% of the frame). They only mean something next to each other, not next to published results.&lt;/p&gt;

&lt;h2&gt;
  
  
  SAD scores a hard edge and a soft edge the same
&lt;/h2&gt;

&lt;p&gt;SAD adds up |predicted − true| over the region. A pixel that is off by 0.5 costs the same whether it sits on a crisp step or inside a feathered fringe.&lt;/p&gt;

&lt;p&gt;Chromakey at similarity 0.10 and blend 0, whose output is pure 0/1, scored 3.88. Control A, the true alpha blurred with a fixed σ=2 Gaussian, scored 3.91. A tie, with one edge too hard and the other too soft.&lt;/p&gt;

&lt;p&gt;Grad compares the gradient magnitude of the two mattes after a Gaussian derivative filter, so it reacts to the shape of the edge. The hard-edged methods sit together, the threshold at 1.19 and chromakey 0.10 at 1.04, while the AI matte, which keeps a soft fringe, is at 0.53. SAD put the AI matte about a quarter below chromakey 0.10 (2.89 vs 3.88). Grad put it at roughly half.&lt;/p&gt;

&lt;p&gt;Grad doesn't say which way the edge is wrong, though. Blur A reads 0.89 and chromakey 1.04, and both only mean "edge shape is off". Which way it's off takes a look at a crop. It also sees one frame at a time. Control B is the true alpha with fresh random noise (σ=0.06) added inside the edge band every frame. Its SAD is 1.53 and its Grad 0.03, the best in the table. Its flicker on pixels that should stay still was 11.844 (×1000), one of the two worst I measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conn mostly moved with SAD here
&lt;/h2&gt;

&lt;p&gt;Conn, the connectivity error from Rhemann et al. 2009, is meant to catch a different failure: foreground that has come loose from the main body, like background islands or broken hair. It sweeps a threshold and charges pixels that drop out of the largest connected region early. I followed the choices in the public Deep Image Matting evaluation code (0.1 steps, 0.15 cutoff); nobody has compared these numbers with the original Matlab version.&lt;/p&gt;

&lt;p&gt;On the real methods Conn stayed close to SAD every time. Chromakey 0.10 was 3.60 against 3.88, the AI matte 2.67 against 2.89, chromakey 0.25 40.53 against 40.65. The busy-background AI run was 6.80 against 6.97, and that is the run where Conn should have broken away, since its error map for frame 13 shows dark fragments outside the person's outline. Across the six real runs Conn put them in the same order as SAD. The controls are where it moved: control A went from 3.91 to 3.50 and slipped below chromakey 0.10, and control B went from 1.53 to 0.79.&lt;/p&gt;

&lt;p&gt;My guess, and it is only a guess: when most errors are flips between near 0 and near 1, a pixel wrong in value is also wrong in connectivity, so the two sums travel together. Conn would probably matter more on faint detached haze, and I had no case built for that.&lt;/p&gt;

&lt;p&gt;Region matters for both. The whole-frame Conn I have for two methods barely moved from the band value (3.6008 vs 3.6003, 2.6724 vs 2.6718), but islands far from the outline sit outside the band by definition. SAD shows that trap more sharply: chromakey 0.25 is 93.37 over the full frame and 40.65 in the band, because that setting also removed dark garments and footwear as solid blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  I read SAD with Grad and Conn in one row
&lt;/h2&gt;

&lt;p&gt;SAD is how much is wrong. Grad high relative to SAD means the edge profile is off, and a crop tells me which direction. Conn well below SAD means the errors are small and still attached to the subject. None of them looks across frames, so a separate flicker number sits next to them.&lt;/p&gt;

&lt;p&gt;Source clip: "Filming with the green screen", VOA Learning English, Wikimedia Commons, public domain; cut and composited, modified. Backgrounds are generated.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>ffprobe before a batch transcode: the fields worth reading, and the one that comes back N/A on WebM</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Tue, 03 Nov 2026 08:06:01 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/ffprobe-before-a-batch-transcode-the-fields-worth-reading-and-the-one-that-comes-back-na-on-webm-866</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/ffprobe-before-a-batch-transcode-the-fields-worth-reading-and-the-one-that-comes-back-na-on-webm-866</guid>
      <description>&lt;p&gt;Before a batch transcode I only ask ffprobe one thing: is this file worth re-encoding at all? Answering that takes a handful of fields. One of them, the video stream's bit rate, came back as N/A on every one of the five WebM clips I tried, and the fallback for it quietly changes your number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fields I ask for
&lt;/h2&gt;

&lt;p&gt;The number I care about is bits per pixel per frame, bpp for short: video bit rate divided by width × height × frame rate. Low bpp means the file is already squeezed.&lt;/p&gt;

&lt;p&gt;The stream-level fields I ask for are &lt;code&gt;codec_name&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;r_frame_rate&lt;/code&gt; and &lt;code&gt;bit_rate&lt;/code&gt;. From the container I ask for &lt;code&gt;duration&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; and &lt;code&gt;bit_rate&lt;/code&gt;. &lt;code&gt;r_frame_rate&lt;/code&gt; comes back as a fraction such as &lt;code&gt;30/1&lt;/code&gt;, so it needs one division before it is a number.&lt;/p&gt;

&lt;p&gt;On a 10-second 720p H.264 clip, the stream bit rate was 732,591 and the container bit rate was 736,016. Under half a percent apart, so for plain H.264 in MP4 it hardly matters which one you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the bit rate goes missing
&lt;/h2&gt;

&lt;p&gt;These WebM files carry no per-stream bit rate, and ffprobe says so with N/A instead of leaving the line out. This is the 1080p moose clip from my set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; v:0 &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;codec_name,width,height,r_frame_rate,bit_rate:format&lt;span class="o"&gt;=&lt;/span&gt;duration,size,bit_rate &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;nw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 input.webm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the moose clip (VP9 in WebM), the stream-level bit_rate comes back as N/A and the format-level one is about 2.06 Mbps.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bit_rate&lt;/code&gt; shows up twice, stream first and container second. All five WebM clips behaved this way, and a script that calls &lt;code&gt;float()&lt;/code&gt; on the stream value would crash on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fallback does to the number
&lt;/h2&gt;

&lt;p&gt;The fallback I use is file size × 8 ÷ duration. That includes the audio track and the container overhead, so the bpp it gives is higher than the video alone. The table sets it against the bpp of the same clips with audio stripped, and adds what one more x264 pass (CRF 23, preset medium, audio removed) did to the size.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;bpp from the whole file&lt;/th&gt;
&lt;th&gt;bpp from video only&lt;/th&gt;
&lt;th&gt;Size after one more pass ÷ before&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;moose, VP9 1080p&lt;/td&gt;
&lt;td&gt;0.033&lt;/td&gt;
&lt;td&gt;0.032&lt;/td&gt;
&lt;td&gt;2.805&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crowd A, VP8 720p&lt;/td&gt;
&lt;td&gt;0.288&lt;/td&gt;
&lt;td&gt;0.279&lt;/td&gt;
&lt;td&gt;0.348&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crowd B, VP9 720p&lt;/td&gt;
&lt;td&gt;0.243&lt;/td&gt;
&lt;td&gt;0.239&lt;/td&gt;
&lt;td&gt;0.560&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crowd C, VP9 1080p&lt;/td&gt;
&lt;td&gt;0.115&lt;/td&gt;
&lt;td&gt;0.113&lt;/td&gt;
&lt;td&gt;0.831&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;crowd D, VP8 360p&lt;/td&gt;
&lt;td&gt;0.322&lt;/td&gt;
&lt;td&gt;0.264&lt;/td&gt;
&lt;td&gt;0.664&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The overstatement is 1.7% to 3.1% on three clips, 4.7% on the moose clip and about 22% on the 360p one, where the audio is a bigger share of a very small file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did it change a verdict
&lt;/h2&gt;

&lt;p&gt;Not on these five. With a skip line at 0.10 (my choice for an x264 CRF 23 target), the moose clip is skipped and the other four go through, whichever bpp you use. The moose clip came out 2.805 times bigger after the extra pass. The other four shrank, to between 0.348 and 0.831 of their size.&lt;/p&gt;

&lt;p&gt;The clip nearest the line is crowd C, at 0.115 whole-file and 0.113 video-only. Both are above 0.10, and it did save 16.9%. I had no clip sitting just under the line with a loud audio track, and a file like that is exactly where the inflation would push a skip into a transcode. My guess is it would cost little: in a separate 36-point run on H.264 inputs, the four points between 0.08 and 0.13 saved only 4% to 9% when re-encoded, while all 24 points below 0.08 grew, by 1.34 to 6.67 times. That is a guess about the borderline, not something I measured on WebM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fields I print but do not decide on
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;codec_name&lt;/code&gt; goes into my log line and nothing else. In the 36-point run every input was H.264 and in the five-clip check every input was VP8 or VP9, so I have no data on a codec-dependent threshold and did not want to invent one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;duration&lt;/code&gt; and &lt;code&gt;size&lt;/code&gt; only matter as fallback inputs. Variable frame rate, HDR, rotated phone clips and multi-stream files were not in my set.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the batch goes through a browser
&lt;/h2&gt;

&lt;p&gt;If the folder is going through a browser instead of a script, I ran the same five clips through ImgIng &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; video compression on a desktop browser. It takes several files at once and has quality presets. Phones and tablets only do image compression there. During the compression step of that run I saw no non-GET requests.&lt;/p&gt;

&lt;p&gt;With its default smart pick, it kept the originals for four of the five clips, including three the bpp rule would send to transcode. The site says it only adopts a result that saves at least 12% and passes a quality check. That looks like a different rule from bpp, as far as I can tell from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not check
&lt;/h2&gt;

&lt;p&gt;Only five public clips plus 36 generated points, all in one environment. No phone-original H.264 files, no H.265 or AV1 targets, no preset other than medium.&lt;/p&gt;

&lt;p&gt;Sample clips: Wikimedia Commons, re-encoded and trimmed, so modified from the originals. Moose by the road.webm (Forza1000, CC BY-SA 4.0); A Demonstration of Disabled People June 2018 1.webm (DGW, CC BY-SA 3.0); Worldwide Demonstration – March 2021 Amsterdam.webm (Karen Eliot, CC BY-SA 2.0); Demonstration of Ethiopian residents in Israel, July 2019.webm (Israel Police, CC BY-SA 3.0); Demonstration in Al-Zabadani, 23 March 2012.webm (Allfor Syria, CC BY 3.0).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Which GIF knobs change file size in a browser tool: colors, dithering, frame drop, scale</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:05:41 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/which-gif-knobs-change-file-size-in-a-browser-tool-colors-dithering-frame-drop-scale-50ef</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/which-gif-knobs-change-file-size-in-a-browser-tool-colors-dithering-frame-drop-scale-50ef</guid>
      <description>&lt;p&gt;Every GIF tool shows you a different pile of sliders when a file is too big to send. I wanted to know which of them actually move the byte count. The sample is MORPH.gif, 675×599, 10 frames, 3,003,893 bytes, by Kowloonese on Wikimedia Commons under CC BY-SA 3.0. Everything below comes from this one clip, one machine (Apple M4, desktop Chromium), single runs, so read the numbers as "this clip" and not as a rule for GIFs in general.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cutting colors moves the size the most
&lt;/h2&gt;

&lt;p&gt;I used the animation tool in ImgIng &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; on desktop Chromium, GIF output, dithering off, and changed only the color count. The default is 256 colors and gives 1,525,126 bytes. At 128 colors it was 1,337,247, at 64 colors 1,083,830, and at 32 colors 811,310. That is roughly a halving between 256 and 32. For comparison, ezgif's lossy mode at its default level 30 produced 505,987 bytes from the same file and iLoveIMG's page, which has no settings, produced 656,903, so the default ImgIng output was the largest of the three. The settings differ between tools, so this is not a ranking, and the colors setting is what closes part of the gap on the ImgIng side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning dithering on makes the file bigger
&lt;/h2&gt;

&lt;p&gt;With dithering on (Floyd–Steinberg), the same four color counts gave 1,110,431 at 32 colors, 1,339,435 at 64, 1,475,692 at 128 and 1,535,115 at 256. Against dithering off that is +36.9%, +23.6%, +10.4% and +0.7%. In the tool, the fewer colors you keep, the more dithering cost.&lt;/p&gt;

&lt;p&gt;I rebuilt the same comparison outside the tool, with Pillow for the palette and &lt;code&gt;gifsicle -O3&lt;/code&gt; for the final file, on a copy of the clip where every frame was blurred slightly to remove the noise already baked into the original. The gap there was +32% to +50% across 32 to 256 colors. Counting the output codes of a GIF-style LZW encoder, 32 colors went from 418,303 codes with dithering off to 628,637 with it on, a rise of 50.3% that matches the file size rise of 50.4%. My explanation is that dithering scatters single-pixel noise, which breaks up the repeated runs LZW relies on. That part is an inference, not something I verified. The +0.7% at 256 colors in the tool is also unexplained; I suspect the original is already close to 256 colors, but I did not check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quality slider did nothing for GIF
&lt;/h2&gt;

&lt;p&gt;The tool has a quality slider. At 30, 50, 74 and 100 the GIF output was 1,525,126 bytes every time. I read that as the slider belonging to the other output formats, which is a guess; what I saw is only four identical sizes. For GIF the knobs that did something were color count, dithering and, according to the panel, frame optimization, frame drop and scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frame drop and scale were checked outside the browser
&lt;/h2&gt;

&lt;p&gt;I did not run frame drop or scale inside the tool. A supplementary run on the same clip with Pillow and gifsicle, starting from the blurred copy at 256 colors and dithering off (1,364,178 bytes), gives a feel for them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;32 colors&lt;/td&gt;
&lt;td&gt;572,768&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep every other frame&lt;/td&gt;
&lt;td&gt;722,755&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale to 50%&lt;/td&gt;
&lt;td&gt;456,136&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32 colors and scale to 50%&lt;/td&gt;
&lt;td&gt;203,744&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dropping every other frame saved 47.0% and halving the dimensions saved 66.6%. On this clip frame drop is expensive in another way: all ten frames change and the whole animation lasts one second, so half the frames means half the time you are looking at an old picture. In the same supplementary run, doing the 32-color step before or after the 50% scale changed the result by only 987 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lossy in ezgif went furthest on this clip
&lt;/h2&gt;

&lt;p&gt;ezgif processes in the browser by default, with a "Local processing" box ticked on its optimize page (checked 2026-10-30). On the same file its Lossy GIF mode gave 505,987 bytes at level 30, 427,190 at level 60 and 331,817 at level 200. When I ran gifsicle on the original with &lt;code&gt;--lossy=30&lt;/code&gt; and nothing else, the result was 1,511,531, so ezgif must pass more than that one option. I did not find out which. During the compress step in ImgIng I saw no request carrying the file, which is only this run and not a claim about every feature.&lt;/p&gt;

&lt;p&gt;Two more runs use gifsicle's own color reduction on the original clip, with dithering off and then forced on. That is a different condition from the tables above, so do not compare the numbers across sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;gifsicle &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;--colors&lt;/span&gt; 32 &lt;span class="nt"&gt;--no-dither&lt;/span&gt; IN.gif &lt;span class="nt"&gt;-o&lt;/span&gt; small.gif&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z small.gif
426315
&lt;span class="nv"&gt;$ &lt;/span&gt;gifsicle &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;--colors&lt;/span&gt; 32 &lt;span class="nt"&gt;-f&lt;/span&gt; IN.gif &lt;span class="nt"&gt;-o&lt;/span&gt; small.gif&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z small.gif
1005103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phones and tablets only get image compression on the ImgIng site; for animation it asks you to open a desktop browser.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why the same A4 page at 300 DPI is 2480 or 2481 pixels wide depending on the tool</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:04:12 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/why-the-same-a4-page-at-300-dpi-is-2480-or-2481-pixels-wide-depending-on-the-tool-2fnd</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/why-the-same-a4-page-at-300-dpi-is-2480-or-2481-pixels-wide-depending-on-the-tool-2fnd</guid>
      <description>&lt;p&gt;Ask two PDF converters for the same A4 page at 300 DPI and one hands back a PNG 2481 pixels wide while the other says 2480. Nothing is broken, and a calculator is enough to see why. I ran one A4 page (595.276 × 841.89 pt, generated by me) through four things on a single Apple M4 machine, once each: poppler's pdftoppm 26.08.0, PyMuPDF 1.26.5, pdf.js 4.4.168 in a desktop Chromium 149, and ImgIng, a PDF to image tool in the desktop browser at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; with its three presets of 144, 216 and 300 DPI. In the runs I watched, ImgIng sent no request that carried the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four tools, three answers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;A4 at 300 DPI&lt;/th&gt;
&lt;th&gt;Width × height&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pdftoppm&lt;/td&gt;
&lt;td&gt;2481 × 3508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyMuPDF&lt;/td&gt;
&lt;td&gt;2481 × 3508&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pdf.js, canvas sized with Math.floor&lt;/td&gt;
&lt;td&gt;2480 × 3507&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng, 300 DPI preset&lt;/td&gt;
&lt;td&gt;2480 × 3508&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pdf.js row is partly my doing. pdf.js hands you a viewport with fractional sizes, and the floor comes from my own line that creates the canvas. I did not try round or ceil there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fraction decides
&lt;/h2&gt;

&lt;p&gt;Pixels are points × DPI ÷ 72, and an A4 page is not a whole number of points. At 300 DPI the width is 595.276 × 300 ÷ 72 = 2480.317 and the height is 3507.875. Every tool has to turn those into integers, and there are three common ways: drop the fraction, round to the nearest, or go up.&lt;/p&gt;

&lt;p&gt;The width fraction is .317, so dropping it and rounding both give 2480 and only going up gives 2481. The height fraction is .875, so rounding and going up both give 3508 and only dropping it gives 3507. That is the whole table above. pdftoppm and PyMuPDF behave like ceil on every size I ran, the browser tool behaves like round to nearest at all three presets (1191 × 1684, 1786 × 2526, 2480 × 3508), and my pdf.js canvas is the floor. That last part is read off its outputs, I cannot see how it is built.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the gap shows up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;A4&lt;/th&gt;
&lt;th&gt;Exact&lt;/th&gt;
&lt;th&gt;Round&lt;/th&gt;
&lt;th&gt;Ceil&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;72 DPI&lt;/td&gt;
&lt;td&gt;595.276 × 841.890&lt;/td&gt;
&lt;td&gt;595 × 842&lt;/td&gt;
&lt;td&gt;596 × 842&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;144 DPI&lt;/td&gt;
&lt;td&gt;1190.552 × 1683.780&lt;/td&gt;
&lt;td&gt;1191 × 1684&lt;/td&gt;
&lt;td&gt;1191 × 1684&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300 DPI&lt;/td&gt;
&lt;td&gt;2480.317 × 3507.875&lt;/td&gt;
&lt;td&gt;2480 × 3508&lt;/td&gt;
&lt;td&gt;2481 × 3508&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two camps disagree only when a fraction falls below .5, so the one-pixel gap appears at 72 and 300 DPI but not at 144. In my run pdftoppm and the browser tool both gave 1191 × 1684 there. At 216 DPI the fractions are .83 and .67, so by arithmetic they should agree again, though I only ran the browser tool at that setting (1786 × 2526). Pages with whole-point sizes never split: Letter at 300 DPI was 2550 × 3300 in every tool I ran it through (pdftoppm, PyMuPDF, pdf.js and ImgIng), and a 504 × 720 pt brochure page, from a 4-page Wikimedia Commons sample (Wiki Education Foundation, CC BY-SA 3.0), came out as 2100 × 3000 in Smallpdf, PDF24 (after I set 300) and the browser tool alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking for a width instead
&lt;/h2&gt;

&lt;p&gt;If a layout needs one exact width, skip DPI and ask for the width. This is the full output of two runs with pdftoppm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pdftoppm &lt;span class="nt"&gt;-r&lt;/span&gt; 300 &lt;span class="nt"&gt;-png&lt;/span&gt; &lt;span class="nt"&gt;-singlefile&lt;/span&gt; A4.pdf A4-300
&lt;span class="nv"&gt;$ &lt;/span&gt;file A4-300.png
A4-300.png: PNG image data, 2481 x 3508, 8-bit/color RGB, non-interlaced
&lt;span class="nv"&gt;$ &lt;/span&gt;pdftoppm &lt;span class="nt"&gt;-scale-to-x&lt;/span&gt; 1000 &lt;span class="nt"&gt;-scale-to-y&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;-png&lt;/span&gt; &lt;span class="nt"&gt;-singlefile&lt;/span&gt; A4.pdf A4-w1000
&lt;span class="nv"&gt;$ &lt;/span&gt;file A4-w1000.png
A4-w1000.png: PNG image data, 1000 x 1415, 8-bit/color RGB, non-interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The width is exactly what I asked for. The height took the rounding instead: 1000 × 841.89 ÷ 595.276 is about 1414.3, and the file is 1415 tall.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before blaming a tool
&lt;/h2&gt;

&lt;p&gt;Read the page size first, then multiply and look at the fraction. If a fraction is under .5, two tools that round differently will differ by one pixel there, and that is the convention, not a bug. If the page is a whole number of points, you will not see it at all.&lt;/p&gt;

&lt;p&gt;Two limits on all this. I only looked at A4 in depth (A3 followed the same pattern in PyMuPDF), and Smallpdf, PDF24 and iLovePDF were only fed the whole-point brochure page, so I cannot say which way they round on A4. Whether any of this matters depends on what comes after the converter, and I did not test that.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Do canvas, createImageBitmap and sharp premultiply alpha when they resize? A lookup from one cutout PNG</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 08:04:13 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/do-canvas-createimagebitmap-and-sharp-premultiply-alpha-when-they-resize-a-lookup-from-one-cutout-5dgi</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/do-canvas-createimagebitmap-and-sharp-premultiply-alpha-when-they-resize-a-lookup-from-one-cutout-5dgi</guid>
      <description>&lt;p&gt;I wanted a one-line answer to a small question: when I shrink a PNG that has a cut-out edge, which of the browser and Node resize calls quietly mix the colour stored under the transparent pixels into the edge? I answered it with a table built from one image. I am a tool person, not an imaging person, so everything below comes from what the pixels did, not from reading library source.&lt;/p&gt;

&lt;p&gt;The test image is a portrait from Wikimedia Commons (CC BY-SA 4.0, by Dmitry Makeev), scaled to 1330×2000 and cut out with ImgIng (图映), a browser tool at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; that I used on a desktop. Every number is this one image, one Apple M4, one run, Chromium 149 and sharp 0.35.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Behaves like&lt;/th&gt;
&lt;th&gt;What I saw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;canvas &lt;code&gt;drawImage&lt;/code&gt;, quality high&lt;/td&gt;
&lt;td&gt;premultiplied&lt;/td&gt;
&lt;td&gt;blue stored under transparent pixels never reached the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;createImageBitmap&lt;/code&gt;, &lt;code&gt;premultiplyAlpha: 'none'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;straight (not premultiplied)&lt;/td&gt;
&lt;td&gt;the hidden blue showed up in the edge pixel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;createImageBitmap&lt;/code&gt;, &lt;code&gt;premultiplyAlpha: 'premultiply'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;premultiplied&lt;/td&gt;
&lt;td&gt;nearer the premultiplied reference (2.689) than the straight one (4.85)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sharp, default resize&lt;/td&gt;
&lt;td&gt;premultiplied&lt;/td&gt;
&lt;td&gt;semi-transparent pixels averaged 109.63 in brightness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sharp, RGB and alpha split by hand&lt;/td&gt;
&lt;td&gt;straight&lt;/td&gt;
&lt;td&gt;the same pixels averaged 125.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For comparison, my own reference code gave 112.0 for premultiplied and 129.48 for straight on those semi-transparent pixels (alpha 10 to 245). That is why I put sharp's default next to the first and the hand-split version next to the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny test you can run yourself
&lt;/h2&gt;

&lt;p&gt;The cleanest check is not the portrait at all. Make an 8-pixel-wide image: four opaque red pixels (255, 0, 0, 255) on the left, four fully transparent pixels on the right that still carry the colour blue (0, 0, 255, 0). Shrink it to 5 pixels wide and look at the one pixel in the middle.&lt;/p&gt;

&lt;p&gt;In canvas, &lt;code&gt;drawImage&lt;/code&gt; gave (255, 0, 0, 128): half transparent red, no blue anywhere. &lt;code&gt;createImageBitmap&lt;/code&gt; with &lt;code&gt;premultiplyAlpha: 'none'&lt;/code&gt; gave (128, 0, 128, 128), which is red with blue mixed in. That purple is the kind of fringe a shrunk cutout shows. It is the invisible colour under alpha 0 leaking into the edge.&lt;/p&gt;

&lt;p&gt;Do not use this test for sharp. In my run its 8 to 3, 8 to 5 and 8 to 7 shrinks all came out as hard cuts with no transition pixel, so the two-pixel example tells you nothing about it. For sharp I only trust the real portrait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the portrait numbers are messier
&lt;/h2&gt;

&lt;p&gt;On the portrait, a 4x shrink and enlarge, I compared each call with a straight reference and a premultiplied reference. Canvas was 7.456 away from straight and 6.636 from premultiplied. &lt;code&gt;createImageBitmap&lt;/code&gt; with &lt;code&gt;none&lt;/code&gt; was 6.108 and 5.184. Both lean slightly towards premultiplied, which does not match the tiny example for the &lt;code&gt;none&lt;/code&gt; case.&lt;/p&gt;

&lt;p&gt;I would not read much into that. Each call uses its own resampling kernel, so absolute distances do not compare across calls, and the gaps are only about 0.8 to 0.9. The tiny example has no kernel noise, so I lean on it for these two.&lt;/p&gt;

&lt;h2&gt;
  
  
  sharp flips when you split the channels
&lt;/h2&gt;

&lt;p&gt;Inside sharp the comparison is clean. With the default call, versus resizing RGB and alpha as two separate raw images, the alpha channel matched exactly with lanczos3 (largest difference 0). The RGB did not: over the edge band the average difference was 4.218 and the largest 51.7 with lanczos3, and 4.356 and 50.3 with linear.&lt;/p&gt;

&lt;p&gt;Here is one edge row of the 1330×2000 portrait (row 1300, x = 838, 840, 842, 844, 846, shown as RGBA), taken from my run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sharp_default_lanczos3  (49, 43, 49, 41) (0, 0, 0, 0) (255, 255, 255, 11) (79, 82, 89, 74) (33, 34, 38, 191)
sharp_split_lanczos3    (220, 218, 219, 41) (255, 255, 255, 0) (240, 241, 243, 11) (180, 181, 184, 74) (72, 74, 78, 191)
canvas_high            (45, 41, 45, 163) (60, 55, 60, 98) (85, 76, 85, 30) (81, 81, 81, 22) (47, 47, 47, 65)
bitmap_none            (38, 34, 38, 53) (0, 0, 0, 0) (0, 0, 0, 0) (89, 93, 98, 60) (80, 83, 88, 162)
bitmap_premultiply     (60, 57, 57, 76) (184, 170, 170, 18) (219, 219, 219, 7) (81, 81, 87, 47) (66, 68, 74, 128)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two sharp rows have the same alpha (41, 0, 11, 74, 191) and very different colour: (49, 43, 49) against (220, 218, 219) at the first pixel. The clothing at that spot is dark, so 220 is the pale fringe. Only compare rows within the same library here; the canvas and bitmap rows come from different kernels and their alpha values differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that made me think sharp was broken
&lt;/h2&gt;

&lt;p&gt;My first attempt at the straight version was &lt;code&gt;.removeAlpha().resize()&lt;/code&gt;. It looked reasonable and it was wrong: the RGB came out darker. As far as I can tell, libvips premultiplies by alpha before dropping it, so you get the premultiplied colour with no alpha to put it back. To get a real straight resize I had to export RGB and alpha as two separate raw images, resize each, and join them again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do with this
&lt;/h2&gt;

&lt;p&gt;If you want edges without a pale or dark ring, the default sharp resize, canvas &lt;code&gt;drawImage&lt;/code&gt; and &lt;code&gt;premultiplyAlpha: 'premultiply'&lt;/code&gt; all behaved as premultiplied in my runs. If you have to use &lt;code&gt;none&lt;/code&gt;, or you split channels in sharp, the colour under the transparent pixels matters. On this ImgIng export the pixels at alpha 0 were (0, 0, 0), but the ones at alpha 1 to 5 averaged (221, 218, 219), pale like the old background. Cleaning the colour under low alpha before the resize would probably help; I did not test that.&lt;/p&gt;

&lt;p&gt;One more thing: canvas does not keep the colour under transparent pixels at all. I wrote (200, 100, 50) with alpha 2 using &lt;code&gt;putImageData&lt;/code&gt; and &lt;code&gt;getImageData&lt;/code&gt; returned (255, 128, 0). At alpha 4 it was (191, 128, 64), and at alpha 0 it was (0, 0, 0). So anything that goes through a canvas on its way to a PNG may already have lost its hidden colour. Whether ImgIng's own export goes through a canvas I did not check; I can only say the zeros at alpha 0 look the same.&lt;/p&gt;

&lt;p&gt;Safari and Firefox are untested.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>oxipng after pngquant: about a second per three PNGs, big win on photos, a shrug on illustrations</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:04:58 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/oxipng-after-pngquant-about-a-second-per-three-pngs-big-win-on-photos-a-shrug-on-illustrations-56c9</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/oxipng-after-pngquant-about-a-second-per-three-pngs-big-win-on-photos-a-shrug-on-illustrations-56c9</guid>
      <description>&lt;p&gt;Before deciding whether to keep oxipng behind pngquant, I wanted to know what that second step buys in bytes and in seconds. The check below used 12 PNGs from Wikimedia Commons (CC licenses), three each of screenshots, icons, illustrations and photos, on an Apple M4 with pngquant 3.0.3 and oxipng 10.2.1. Sizes are exact bytes (oxipng prints KiB next to them, but I only quote bytes), and I did not compare image quality at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the second step looks like on one file
&lt;/h2&gt;

&lt;p&gt;Start with the first icon in the set, 40,369 bytes. pngquant at quality 65–80 got it to 7,600 and exited with 0. The extra step is one command, &lt;code&gt;oxipng -o 4 --strip safe&lt;/code&gt; on a copy of the pngquant output, and it took the file to 6,954. oxipng reported 646 bytes saved, which it calls 8.50% because it measures against its own 7,600-byte input.&lt;/p&gt;

&lt;p&gt;Those 646 bytes came off a file that was already about 81 percent smaller than where it started. Whether 646 bytes matters is the whole question, so I ran the same two steps on the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost is small and the gain is uneven
&lt;/h2&gt;

&lt;p&gt;Across all 12 files, pngquant took the set from 4,454,411 bytes to 1,411,877 (down 68.3 percent). Adding oxipng brought it to 1,283,130 (down 71.2 percent), so the second step removed another 128,747 bytes. In a separate timed run over the same files, pngquant took about 1.1 seconds in total and oxipng about 4.4 seconds. Per group of three files, the oxipng step landed between 0.78 and 1.51 seconds. That is the "about one extra second" in the title.&lt;/p&gt;

&lt;p&gt;The gain per group, measured against each group's original size, was not even:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;pngquant 65–80&lt;/th&gt;
&lt;th&gt;Plus oxipng&lt;/th&gt;
&lt;th&gt;Extra points&lt;/th&gt;
&lt;th&gt;Bytes saved by oxipng&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photos&lt;/td&gt;
&lt;td&gt;66.3%&lt;/td&gt;
&lt;td&gt;70.3%&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;81,767&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;td&gt;79.8%&lt;/td&gt;
&lt;td&gt;82.9%&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;5,677&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;68.2%&lt;/td&gt;
&lt;td&gt;70.1%&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;36,305&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Illustrations&lt;/td&gt;
&lt;td&gt;75.3%&lt;/td&gt;
&lt;td&gt;76.9%&lt;/td&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;4,998&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I have no explanation for photos gaining the most, and three files per group will not carry a rule. The icons are tiny files, so their percentages swing a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it pays off and where it does not
&lt;/h2&gt;

&lt;p&gt;Reading the table as someone who just wants smaller files: for the photos, 81,767 bytes for under 1.5 seconds is an easy yes. For the icons and the illustrations, the oxipng step saves around 5,000 bytes per group of three. If those files ship once and sit behind a CDN, I would still run it, because it costs nothing to type. If a build runs it on every commit across thousands of small files, the seconds would probably add up faster than the bytes (I did not time that), and I would run it only on release.&lt;/p&gt;

&lt;p&gt;The step I would skip is zopflipng. After pngquant, it added 0.0 to 0.5 extra points, and a group of three took between 3.5 and 19.5 seconds. One caveat on that comparison: oxipng also does lossless bit-depth and palette reductions, and zopflipng with the flags I used mostly recompresses the deflate stream. So this does not say zopfli is a worse algorithm. I did not try more iterations.&lt;/p&gt;

&lt;p&gt;Order matters too. With oxipng in front of pngquant, the photos finished within 500 bytes of pngquant alone, so the lossless pass looks wasted there. I only run it behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more place I ran it
&lt;/h2&gt;

&lt;p&gt;The other PNG source I had was the batch compressor in ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), which I ran in a desktop browser with the output forced to PNG. I watched the requests while it ran and saw none carrying a file, so the processing looks local. That mode has a catch: 7 of the 12 PNGs came out larger than the originals, and one kept the original file. Only four outputs were palette PNGs, and those are the ones I fed to oxipng.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;ImgIng output (bytes)&lt;/th&gt;
&lt;th&gt;After oxipng (bytes)&lt;/th&gt;
&lt;th&gt;Saved vs ImgIng output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adaptability icon&lt;/td&gt;
&lt;td&gt;9,346&lt;/td&gt;
&lt;td&gt;6,301&lt;/td&gt;
&lt;td&gt;32.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Afro Mobile icon&lt;/td&gt;
&lt;td&gt;8,324&lt;/td&gt;
&lt;td&gt;5,946&lt;/td&gt;
&lt;td&gt;28.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anime girl illustration&lt;/td&gt;
&lt;td&gt;90,040&lt;/td&gt;
&lt;td&gt;61,748&lt;/td&gt;
&lt;td&gt;31.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blue star icon&lt;/td&gt;
&lt;td&gt;43,216&lt;/td&gt;
&lt;td&gt;23,571&lt;/td&gt;
&lt;td&gt;45.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four files, one run, so this says palette PNGs from a browser tool can still shrink losslessly, nothing more. It is not a ranking of tools, and I compared nothing about quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I settled on
&lt;/h2&gt;

&lt;p&gt;pngquant first, oxipng second, nothing after. I leave zopflipng out, and if I ever add a per-commit hook it will only touch changed files. All of this is 12 files on one machine, one run, with image quality left unchecked.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why my converted PDF page showed a different font: it was Type 1, not a license flag</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:04:40 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/why-my-converted-pdf-page-showed-a-different-font-it-was-type-1-not-a-license-flag-2kij</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/why-my-converted-pdf-page-showed-a-different-font-it-was-type-1-not-a-license-flag-2kij</guid>
      <description>&lt;p&gt;I converted a 4-page brochure PDF to HTML and the headline font survived while the body text came out in something else. Same page, same words, different typeface. A font license flag such as fsType is one possible explanation, but in this file it was not the cause, and the real one was easy to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I converted
&lt;/h2&gt;

&lt;p&gt;The sample is a public brochure from the Wikimedia Commons file "Evaluating Wikipedia" (Wiki Education Foundation and others, CC BY-SA 3.0). I cut the first 4 pages out of the 8-page original, which came to 1,180,300 bytes. Everything below is from this one file, on my M4 Mac, run once, in Chromium 149.&lt;/p&gt;

&lt;p&gt;For the conversion I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), which runs in a desktop browser, is labelled Beta, and processes the file locally. After it finished, the headings still looked like the PDF. The body paragraphs did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the font went
&lt;/h2&gt;

&lt;p&gt;The result panel told me about it. It listed a font substitution for pages 2 to 4, and a second line saying the PDF carries a font stream the browser cannot use directly, so a safe replacement was used. I am paraphrasing here, because the panel is in Chinese.&lt;/p&gt;

&lt;p&gt;In the HTML, 509 text elements were set in &lt;code&gt;Arial, sans-serif&lt;/code&gt;. The body face in the PDF was a serif, so the change was easy to see once I looked for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Listing the fonts inside the PDF
&lt;/h2&gt;

&lt;p&gt;To see what the source actually contained, I listed the embedded fonts with PyMuPDF and read each one's OS/2 table with fontTools. This is the complete output for the sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="nf"&gt;RMLRZN+NewsGothicBT-Bold&lt;/span&gt; &lt;span class="nf"&gt;TrueType&lt;/span&gt; &lt;span class="nf"&gt;ttf&lt;/span&gt; &lt;span class="nf"&gt;fsType&lt;/span&gt; &lt;span class="nf"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4&lt;/span&gt;
&lt;span class="nf"&gt;RMLRZN+ArialMT&lt;/span&gt; &lt;span class="nf"&gt;TrueType&lt;/span&gt; &lt;span class="nf"&gt;ttf&lt;/span&gt; &lt;span class="nf"&gt;fsType&lt;/span&gt; &lt;span class="nf"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;8&lt;/span&gt;
&lt;span class="nf"&gt;RMLRZN+PlantinMTStd-Regular&lt;/span&gt; &lt;span class="nf"&gt;Type1&lt;/span&gt; &lt;span class="nf"&gt;cff&lt;/span&gt; &lt;span class="nf"&gt;fsType&lt;/span&gt; &lt;span class="nf"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;None&lt;/span&gt;
&lt;span class="nf"&gt;RMLRZN+NewsGothicBT-Demi&lt;/span&gt; &lt;span class="nf"&gt;TrueType&lt;/span&gt; &lt;span class="nf"&gt;ttf&lt;/span&gt; &lt;span class="nf"&gt;fsType&lt;/span&gt; &lt;span class="nf"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4&lt;/span&gt;
&lt;span class="nf"&gt;RMLRZN+NewsGothicBT-Roman&lt;/span&gt; &lt;span class="nf"&gt;TrueType&lt;/span&gt; &lt;span class="nf"&gt;ttf&lt;/span&gt; &lt;span class="nf"&gt;fsType&lt;/span&gt; &lt;span class="nf"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of the five are TrueType subsets. The fifth, PlantinMTStd, is Type 1, and the OS/2 table, where fsType lives, does not exist in that kind of font file. That is why the last column says &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the output against the result
&lt;/h2&gt;

&lt;p&gt;The ImgIng output had four inlined fonts: the three NewsGothicBT cuts and ArialMT. Those four are exactly the TrueType ones. Plantin was the one that never got inlined, and the 509 elements on &lt;code&gt;Arial, sans-serif&lt;/code&gt; are its text.&lt;/p&gt;

&lt;p&gt;So the fsType values (4 and 8) were not the reason anything changed, because those fonts went through. The font that fell back is the one with no fsType to read. In this sample the split followed the font type. I had no font with fsType 2 in it, so this says nothing about restricted fonts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check before converting
&lt;/h2&gt;

&lt;p&gt;My working assumption, drawn from this one file, is that a Type1 line in the PDF's font list marks a font likely to change in the HTML, and a TrueType line marks one likely to survive as a subset. Listing the fonts is quicker than converting and then hunting for the wrong typeface on page 3.&lt;/p&gt;

&lt;p&gt;A subset only holds the characters used in the PDF, so an inlined font is good for showing that page and not much else. If the typeface matters for your page, one thing worth looking at is whether the file you export the PDF from can output TrueType or OpenType fonts. I have not tested re-exporting the brochure that way, so I cannot say it fixes this sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;One 4-page file, one machine, one run. Other PDFs may split differently, and I did not test other converters on this font. The page-1 cover image also came out odd in my run, which is a separate problem and not part of the font story.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A WebM can say alpha_mode 1 and have no alpha, and the reverse also happens</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:32:29 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/a-webm-can-say-alphamode-1-and-have-no-alpha-and-the-reverse-also-happens-1lje</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/a-webm-can-say-alphamode-1-and-have-no-alpha-and-the-reverse-also-happens-1lje</guid>
      <description>&lt;p&gt;I came to video from content work, not from a codec background, so my first instinct with a transparent WebM was to check the metadata. If a file says &lt;code&gt;alpha_mode: 1&lt;/code&gt;, it has alpha, right? I built two test files to check that, and both break the idea: one whose tag says alpha while the video holds none, and one that holds alpha with no tag at all.&lt;/p&gt;

&lt;p&gt;Everything below comes from one 8-second, 540×960 clip with a person on a partly green backdrop, plus a 2-second clip FFmpeg encoded for the second counterexample, all run through FFmpeg 7.1 on one machine, once. The transparent WebM came out of ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a browser tool whose video matting is still in Beta and runs locally in desktop Chrome or Edge. Treat the numbers as a single sample, not a general result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tag shows up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ffmpeg -i&lt;/code&gt; prints the tag under the video stream's metadata. The file exported from the tool shows &lt;code&gt;alpha_mode : 1&lt;/code&gt; in lowercase. Files FFmpeg writes itself show &lt;code&gt;ALPHA_MODE : 1&lt;/code&gt; in uppercase, so a case-sensitive grep can miss the ones FFmpeg wrote.&lt;/p&gt;

&lt;p&gt;Reading the tag takes one command. The next two files are why I stopped trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counterexample one: the tag stays, the alpha goes
&lt;/h2&gt;

&lt;p&gt;I decoded the transparent file and re-encoded it as plain yuv420p. The output still printed &lt;code&gt;ALPHA_MODE : 1&lt;/code&gt;. FFmpeg copies the input stream's tags onto the output, so the label rode along into a file that has no alpha data. This is a side effect of re-encoding with FFmpeg, not something the original export does. Any re-encode step that keeps the stream tags would leave a file like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counterexample two: the alpha stays, the tag goes
&lt;/h2&gt;

&lt;p&gt;I encoded a 2-second clip with alpha through libvpx-vp9, then stripped the tag with &lt;code&gt;-c copy -metadata:s:v:0 alpha_mode=&lt;/code&gt;. The tag disappeared from &lt;code&gt;ffmpeg -i&lt;/code&gt;. Decoding with &lt;code&gt;-c:v libvpx-vp9&lt;/code&gt; still returned a real alpha channel on frame 30 (min 0, max 255, mean 45.48). Again this is something FFmpeg did to my file, but it shows the data and the label are stored separately and can disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;A VP9 alpha channel in WebM is stored as extra data attached to each video block (BlockAdditions in the Matroska structure). If the blocks carry it, the alpha is there, whatever the tag says. &lt;code&gt;ffmpeg -i&lt;/code&gt; does not show that, and I did not have ffprobe or mkvinfo on this machine, so I used a short script that walks the file structure and counts the blocks with extra data. I did not try ffprobe's packet side data, so I can't say whether that route works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 check_blockadditions.py input.webm
input.webm {'AlphaMode': 1, 'BlockGroups': 240, 'BlockAdditions_count': 240, 'BlockAdditional_bytes': 1036225, 'SimpleBlocks': 401}
$ python3 check_blockadditions.py noflag.webm
noflag.webm {'BlockGroups': 60, 'BlockAdditions_count': 60, 'BlockAdditional_bytes': 248054}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exported file has 240 blocks with extra data, about 1.0 MB of it. The stripped file has no &lt;code&gt;AlphaMode&lt;/code&gt; entry at all, yet all 60 of its blocks still carry alpha. The re-encoded file from counterexample one has the tag and no BlockAdditions, which is how you tell it apart from the real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet failure that makes this matter
&lt;/h2&gt;

&lt;p&gt;Even with real alpha in the file, FFmpeg's default VP9 decoder ignores it. On frame 60 of the exported clip, the default decode gave alpha 255 everywhere. Adding &lt;code&gt;-c:v libvpx-vp9&lt;/code&gt; before &lt;code&gt;-i&lt;/code&gt; gave 0 to 255, with a mean of 46.762. If you convert with the default decoder, ProRes 4444 or QuickTime Animation files are still produced and still play, but every frame is opaque. There is no error and no warning; the stream line just says yuv420p.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops
&lt;/h2&gt;

&lt;p&gt;This is one clip, one FFmpeg build, one run. I did not test other FFmpeg versions, and I did not check how any editing app imports a transparent WebM, so I have nothing to say about that.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>HandBrake two-pass vs hand-written FFmpeg two-pass: which lands closer to a 10 MB target?</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:34:51 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/handbrake-two-pass-vs-hand-written-ffmpeg-two-pass-which-lands-closer-to-a-10-mb-target-3j93</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/handbrake-two-pass-vs-hand-written-ffmpeg-two-pass-which-lands-closer-to-a-10-mb-target-3j93</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%2Fff36aa2b6j0saobc27vz.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%2Fff36aa2b6j0saobc27vz.png" alt=" " width="800" height="451"&gt;&lt;/a&gt;&lt;br&gt;
I am not a video person. I came into tech from a content-ops job, so I use whatever tool gets a clip under a size limit without making me read a manual. When the limit is a hard number, say 10 MB for an upload form, I had two options on my machine: HandBrake with its two-pass setting, or a two-pass command I type into FFmpeg myself. I wanted to know which one actually lands closer to the number, so I ran both on the same clip with the same bitrate.&lt;/p&gt;

&lt;p&gt;Everything below is one run per setting, on an M4 Mac with HandBrake 1.11.2 and FFmpeg 7.1. Clip A is 60 seconds of 1080p, 30 fps street-performance footage by Isak Zvegelj (CC BY 3.0, Wikimedia Commons), re-encoded to a phone-level bitrate first, so it is not a phone original. Clip B is a screen recording I generated myself, so it is quieter and cleaner than a real one. Treat the numbers as "these two clips", not as a rule.&lt;/p&gt;
&lt;h2&gt;
  
  
  Both got the same bitrate
&lt;/h2&gt;

&lt;p&gt;The video bitrate I fed both tools is target size × 8 ÷ 60 seconds, minus 128 kbps for the audio track. That is 1205 kbps for 10 MB and 3205 kbps for 25 MB. Audio is AAC 128 kbps in both. HandBrake's own log shows it encoded at 1205 and 3205 kbps, the same numbers I gave FFmpeg.&lt;/p&gt;

&lt;p&gt;Here is the one difference I did not remove. FFmpeg ran x264 with &lt;code&gt;-preset medium&lt;/code&gt;. HandBrake ran its built-in "Fast 1080p30" preset, which brings its own x264 settings. So this is "the tool as most people would click it" against "the command as I wrote it", not a controlled x264 experiment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ffmpeg -i A.mp4 -c:v libx264 -preset medium -b:v 1205k -pass 1 -an -f null /dev/null
ffmpeg -i A.mp4 -c:v libx264 -preset medium -b:v 1205k -pass 2 -c:a aac -b:a 128k out.mp4
HandBrakeCLI -i A.mp4 -o out.mp4 --preset "Fast 1080p30" -b 1205 --multi-pass -B 128
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  On the action clip both land within 2 percent
&lt;/h2&gt;

&lt;p&gt;MB here means 1,000,000 bytes. If your limit is written in MiB (1,048,576 bytes per MB), a "10 MiB" cap is 10,485,760 bytes and you have about 4.9% more room, so check which one your form means.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip A&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Output (bytes)&lt;/th&gt;
&lt;th&gt;Over target&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HandBrake Fast 1080p30, two-pass&lt;/td&gt;
&lt;td&gt;10 MB&lt;/td&gt;
&lt;td&gt;10,120,027&lt;/td&gt;
&lt;td&gt;+1.20%&lt;/td&gt;
&lt;td&gt;30.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FFmpeg x264 medium, two-pass&lt;/td&gt;
&lt;td&gt;10 MB&lt;/td&gt;
&lt;td&gt;10,174,131&lt;/td&gt;
&lt;td&gt;+1.74%&lt;/td&gt;
&lt;td&gt;25.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HandBrake Fast 1080p30, two-pass&lt;/td&gt;
&lt;td&gt;25 MB&lt;/td&gt;
&lt;td&gt;25,019,191&lt;/td&gt;
&lt;td&gt;+0.08%&lt;/td&gt;
&lt;td&gt;34.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FFmpeg x264 medium, two-pass&lt;/td&gt;
&lt;td&gt;25 MB&lt;/td&gt;
&lt;td&gt;25,069,942&lt;/td&gt;
&lt;td&gt;+0.28%&lt;/td&gt;
&lt;td&gt;34.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HandBrake finished closer both times, by about half a percentage point at 10 MB and 0.2 at 25 MB. My reading, and it is a reading, not something I tested: the delivered video bitrates were 1216 and 1218 kbps, so the two encoders produced almost the same video, and the gap is small enough that I would not pick a tool because of it. What I would take from the table is the sign. All four files came out over the target, even after I took the audio off the budget. If your limit is strict, aim a couple of percent low.&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in 25 MB preset plays it safe
&lt;/h2&gt;

&lt;p&gt;HandBrake also ships a "Social 25 MB" preset. On the same clip it gave 23,596,928 bytes, which is 5.6% under 25 MB, at 1280×720. That is the opposite trade: it will not bust your limit, it leaves some size on the table, and it quietly drops the resolution. For an upload form that rejects oversize files, that is a reasonable default. For a clip that needs to stay 1080p, the manual bitrate was the one that kept it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen recording is where both tools slip
&lt;/h2&gt;

&lt;p&gt;The same two-pass setup on clip B at 10 MB: HandBrake gave 11,053,279 bytes (+10.5%), FFmpeg gave 11,195,510 bytes (+12.0%). Both overshot by a lot more than on the action clip. My guess is that a mostly static screen recording gives the encoder very uneven demands from second to second. That is an inference. The data only shows the overshoot.&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="Deviation from target size for single-pass, two-pass and CRF on both clips, x264 medium and AAC 128 kbps" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What to look at in this chart (it shows the FFmpeg runs, not HandBrake): the two-pass bars on the action clip sit just above the target line, and the two-pass bar on the screen recording at 10 MB is the tall one. Clip A credit: Isak Zvegelj, CC BY 3.0. Clip B is synthetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a no-install tool fits
&lt;/h2&gt;

&lt;p&gt;If you only want a free compressor and do not want either command line, ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) runs in a desktop browser, and its video page says processing happens locally. I saw no upload requests across 11 tasks. It does not work on phones. It also has no "compress to N MB" box. On clip A its smallest-size preset gave 10,487,546 bytes at 1080×608 and 24 fps, and its high-quality preset with the longest edge set to 1280 gave 25,938,497 bytes. Those are presets landing near a number, not a setting aimed at it, so for a hard cap I still pick bitrate by hand. Free-tier limits of online tools change, so read their pages on the day you use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do now
&lt;/h2&gt;

&lt;p&gt;For a hard cap, I would take the bitrate formula, subtract 128 kbps for audio, run HandBrake or FFmpeg two-pass, and then check the file size before uploading. If it is over, lower the bitrate by roughly the overage and run again. For a static screen recording I would start about 12 percent lower, since that is how far the synthetic one overshot. Both come from two clips, so run your own file once before you trust them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Splitting OCR table columns: gap method or projection valleys? It came down to the header</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:11:31 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/splitting-ocr-table-columns-gap-method-or-projection-valleys-it-came-down-to-the-header-431</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/splitting-ocr-table-columns-gap-method-or-projection-valleys-it-came-down-to-the-header-431</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%2Fzl92jk71cix2smg63lth.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%2Fzl92jk71cix2smg63lth.png" alt=" " width="800" height="443"&gt;&lt;/a&gt;&lt;br&gt;
I had tesseract word boxes and needed columns. The write-ups I found use one of two recipes: start a new cell wherever the gap between neighbouring words is big, or stack every row on the x axis and cut at the empty stripes. I ran both on the same three tables. Short version: neither won. They break on different tables, and I'd rather live with the break I can spot faster. Setup: tesseract 5.5.3 with the English pack on an Apple M4 Mac, three tables I generated myself (English and digits only, 63, 63 and 45 cells), and scoring where a cell counts only if its text is exactly right and sits in the right column of the row that reads most like it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two recipes and the settings I fixed first
&lt;/h2&gt;

&lt;p&gt;The gap method starts a new cell when the space between two words is wider than 1.0 times the median word height. The projection method overlays all rows on the x axis; a stripe at least 8 px wide where 80% of rows are empty becomes a column border. I picked 1.0, 80% and 8 px before I saw a single result.&lt;/p&gt;

&lt;p&gt;One prep step: for the two bordered tables I painted the ruling lines white first. Straight off the bordered score table, tesseract found the text of 1 of 63 cells. After the lines were gone it found 62.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither method won across the three tables
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Gap method&lt;/th&gt;
&lt;th&gt;Projection valleys&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bordered scores, lines removed&lt;/td&gt;
&lt;td&gt;84% (53/63)&lt;/td&gt;
&lt;td&gt;95% (60/63)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Borderless quote&lt;/td&gt;
&lt;td&gt;100% (63/63)&lt;/td&gt;
&lt;td&gt;100% (63/63)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merged header, lines removed&lt;/td&gt;
&lt;td&gt;91% (41/45)&lt;/td&gt;
&lt;td&gt;31% (14/45)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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="Cell accuracy of three column-splitting settings on three tables" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The orange bar in the last group is projection at 80%, the only bar under 80% anywhere. The green bar next to it is the same method with the empty-row share dropped to 60%. &lt;/p&gt;

&lt;h2&gt;
  
  
  Each one broke on a different table
&lt;/h2&gt;

&lt;p&gt;The gap rule failed on the bordered scores. &lt;code&gt;Wang Xiaowen&lt;/code&gt; sat closer to the 18-digit ID after it than one word height, so the two became one cell. The header &lt;code&gt;Math Physics English&lt;/code&gt; glued into a single cell as well, and that pushed everything after it one column over.&lt;/p&gt;

&lt;p&gt;The projection rule failed on the merged header. The title row and the &lt;code&gt;East / North&lt;/code&gt; and &lt;code&gt;Units / Sales&lt;/code&gt; rows run across several columns, so the valleys were no longer empty in 80% of the rows. I got 5 columns instead of 6, &lt;code&gt;521 29,860&lt;/code&gt; landed in one cell, and the title was cut in half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sweeping the settings afterward showed the cliffs
&lt;/h2&gt;

&lt;p&gt;After seeing the results I swept each setting on the same samples.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Bordered&lt;/th&gt;
&lt;th&gt;Borderless&lt;/th&gt;
&lt;th&gt;Merged header&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gap at 0.6 / 0.8 / 1.0 / 1.5 / 2.0 × word height&lt;/td&gt;
&lt;td&gt;100 / 95 / 84 / 84 / 75&lt;/td&gt;
&lt;td&gt;100 / 100 / 100 / 11 / 11&lt;/td&gt;
&lt;td&gt;91 at all five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projection at 90 / 80 / 70 / 60 / 50% rows empty&lt;/td&gt;
&lt;td&gt;100 / 95 / 95 / 95 / 95&lt;/td&gt;
&lt;td&gt;100 at all five&lt;/td&gt;
&lt;td&gt;31 / 31 / 93 / 93 / 93&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap knob has a cliff on the borderless quote: from 1.5 on, columns like &lt;code&gt;Price&lt;/code&gt; and &lt;code&gt;Qty&lt;/code&gt; get glued and the score falls to 11%. Gap at 0.6 hits 100% on the first two tables, and I don't trust it, since I found it only by looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  I pick the failure I can see
&lt;/h2&gt;

&lt;p&gt;I now start with projection. That is my reading of how the two fail, not something I measured. Projection gives one column count for the whole table, so a count that disagrees with the image is an immediate warning. The gap method decides row by row, so glued cells show up only as rows with different cell counts. With several spanning header rows I'd still use gap, which held 91% on that table at every setting I tried.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImgIng gave me text but not all the columns
&lt;/h2&gt;

&lt;p&gt;For a ready-made baseline I ran the same tables through ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a browser OCR page that works on a desktop, using its default Professional model. On the clean tables it found the text of every cell, in both the Chinese and English versions, with no wrong characters. Its copied text separates columns with tabs, but only some of them: 4 to 6 cells per row on the 7-column English score table, and 2 to 6 on the English quote. I counted by splitting the text on tabs myself, not in a spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these numbers cover
&lt;/h2&gt;

&lt;p&gt;Every tesseract number above is for English and digit tables only, because this machine has no Chinese language pack for it. The three tables are clean, drawn by a script, and far tidier than a real scan, and every score is from that one batch. I haven't tried faint ruling lines from a real scan.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Four PDF image extractors, four tricky images: three agreed on all of them</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:05:01 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/four-pdf-image-extractors-four-tricky-images-three-agreed-on-all-of-them-4h7g</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/four-pdf-image-extractors-four-tricky-images-three-agreed-on-all-of-them-4h7g</guid>
      <description>&lt;p&gt;I wanted to know whether the choice of PDF image extractor matters at all. So I made one test file with four awkward images and ran four extractors on it. Three of them returned identical pixels on all four images. The fourth matched on three, and on the last one its colours were the reverse of what the page shows.&lt;/p&gt;

&lt;p&gt;The file is one page of PDF bytes I wrote by hand, with four lossless images. Each has a red corner top left, green top right, blue bottom left, yellow bottom right and a big letter in the middle, so I can see which way it faces. A is hidden by a clip path so the page shows only its left half. B is rotated 90° counter-clockwise by the transformation matrix. C is mirrored horizontally. D is a 16-colour palette image with &lt;code&gt;/Decode [15 0]&lt;/code&gt;, which swaps palette entries 0 and 15, so the page shows it with reversed colours. (For a 4-bit palette the range is &lt;code&gt;[15 0]&lt;/code&gt;. &lt;code&gt;[255 0]&lt;/code&gt; is the 8-bit version.)&lt;/p&gt;

&lt;p&gt;The tools were pdfimages 26.08.0, PyMuPDF 1.26.5, the decoded bitmap from pdf.js 4.10.38 in Chromium 149, and ImgIng in a desktop browser. pdf.js has no public extract call, so "bitmap" is the closest thing I could get. One file, default settings, and everything below applies to this file only. Sizes are in pixels; pt is 1/72 inch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came back
&lt;/h2&gt;

&lt;p&gt;The hash is the first 12 hex digits of a SHA-256 over the extracted RGB bytes. pdfimages, PyMuPDF and pdf.js gave the same value on every row.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;What the page shows&lt;/th&gt;
&lt;th&gt;What came back&lt;/th&gt;
&lt;th&gt;Hash&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;left half only&lt;/td&gt;
&lt;td&gt;whole image, 240×160&lt;/td&gt;
&lt;td&gt;5ce5032b49b6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;rotated, portrait&lt;/td&gt;
&lt;td&gt;unrotated, landscape, 240×160&lt;/td&gt;
&lt;td&gt;365ad52040b8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;mirrored&lt;/td&gt;
&lt;td&gt;not mirrored, 240×160&lt;/td&gt;
&lt;td&gt;9f619e1c98cc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;colours reversed by /Decode&lt;/td&gt;
&lt;td&gt;160×120, same colours as the page&lt;/td&gt;
&lt;td&gt;4b16acc5dcfa&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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 cases, three extraction tools: size, orientation, color, pixel hash" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the "Visible on the page" and "Extracted" columns together: for A, B and C the file you get does not look like what you saw. The last column is "yes" on every row, so the three tools agree with each other. The figure covers pdfimages, PyMuPDF and pdf.js only, and the PDF is self-built, with no third-party material.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same pixels, different extras
&lt;/h2&gt;

&lt;p&gt;For just getting the pictures out I couldn't separate the three. They differ in what comes with the pixels.&lt;/p&gt;

&lt;p&gt;pdfimages writes files and lists every image with its type, size and resolution. For A it showed 96 ppi, which is 240 pixels drawn 180 pt wide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pdfimages -list sample.pdf
pdfimages -png sample.pdf out/img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PyMuPDF can also tell you where each image sits on the page.&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;for&lt;/span&gt; &lt;span class="n"&gt;info&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_image_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xrefs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is where the rotation lives: B's transform came out as (0, −180, 120, 0, 330, 252). A's bbox was (60, 132, 240, 252), the whole 180 pt-wide picture, so the clip is not in it.&lt;/p&gt;

&lt;p&gt;pdf.js gives matrices through &lt;code&gt;getOperatorList()&lt;/code&gt;: B as [0, 180, −120, 0, 450, 540], C as [−180, 0, 0, 120, 240, 300]. A shows up after a clip operation. B's b and c have the opposite signs to PyMuPDF's, and f is measured from the bottom edge instead of the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where ImgIng differed
&lt;/h2&gt;

&lt;p&gt;I also ran the same file through ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser. It listed 4 results and saved a ZIP with &lt;code&gt;image-001&lt;/code&gt; to &lt;code&gt;image-004&lt;/code&gt;, all PNG. A, B and C had the same hashes as the other three. D did not: the hash was 566fb92e549e, the stored palette colours, so red sits on the left where the page and the other three tools have it on the right.&lt;/p&gt;

&lt;p&gt;I don't know why; my guess is that it skips &lt;code&gt;/Decode&lt;/code&gt; for palette images. On a second small file, a grey image and an RGB image with &lt;code&gt;/Decode&lt;/code&gt; both matched the page in pdfimages and in ImgIng, and so did a palette image without &lt;code&gt;/Decode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its limits: one PDF per run; a page with no images gives 0 results, not a page screenshot; several images come as a ZIP with serial-number names, and page numbers appear only on the on-screen cards. On two public Creative Commons PDFs, its 35 JPEG files were byte-identical to what pdfimages wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick
&lt;/h2&gt;

&lt;p&gt;For just the pictures I'd start with pdfimages, since the list tells me what is in the file before I write anything. If the page's look matters, I'd read the matrix and the clip, or render the page and crop (the render route I haven't compared across tools). I also assume files made by other generators behave like this hand-built one, but that is an assumption, and an 8-bit palette with &lt;code&gt;[255 0]&lt;/code&gt; is untested in ImgIng.&lt;/p&gt;

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