DEV Community

Cover image for When can a PNG become a JPEG? Check opacity with sharp stats
Sahadat Husain
Sahadat Husain

Posted on

When can a PNG become a JPEG? Check opacity with sharp stats

A PNG can become a JPEG when every pixel is fully opaque. In Node, (await sharp(file).stats()).isOpaque answers that in one call. The flag is true when the image has no alpha channel, or when the alpha channel is 255 everywhere. On my test files, opaque PNGs shrank from 4,096.3 KB to 293.6 KB as JPEG.

The catch: hasAlpha is the wrong question. A file can carry an alpha channel and never use it.

Why is hasAlpha not enough?

metadata().hasAlpha only says the channel exists. Design tools and screen capture apps often write RGBA files where every alpha value is 255. Those files look exactly like an RGB image, but a hasAlpha check keeps them as PNG forever.

The sharp docs describe isOpaque as true "if the image has no alpha channel or if every pixel is fully opaque" (sharp stats()). That reads the pixels, so it catches the unused channel.

The check, in 25 lines

import sharp from 'sharp';
import { statSync } from 'node:fs';

async function opacity(file) {
  const image = sharp(file);
  const { hasAlpha, channels } = await image.metadata();
  const stats = await image.stats();
  if (!hasAlpha) return { hasAlpha, isOpaque: stats.isOpaque, minAlpha: 255, seeThrough: 0 };
  const alpha = stats.channels[channels - 1];
  // Count the pixels that are not fully opaque.
  const raw = await sharp(file).extractChannel(channels - 1).raw().toBuffer();
  let seeThrough = 0;
  for (const a of raw) if (a < 255) seeThrough++;
  return { hasAlpha, isOpaque: stats.isOpaque, minAlpha: alpha.min, seeThrough };
}

for (const file of process.argv.slice(2)) {
  const o = await opacity(file);
  const jpeg = await sharp(file).flatten({ background: '#ffffff' })
    .jpeg({ quality: 80, mozjpeg: true }).toBuffer();
  console.log(file.padEnd(20), JSON.stringify(o),
    'png', statSync(file).size, 'jpeg', jpeg.length);
}
Enter fullscreen mode Exit fullscreen mode

stats().channels holds one entry per channel, so the last one is alpha. Its min tells you the most transparent pixel in the file. The pixel count is optional; you only need it if you want a tolerance (more on that below).

What did it print on real files?

I ran it on six files: a logo with real transparency, a 1920 × 1280 photo saved as PNG, the same photo with an all-255 alpha channel added, that copy with one corner pixel set to alpha 254, a 2880 × 1800 desktop screenshot, and a 768 × 1376 phone screenshot that is RGBA.

transparent.png      {"hasAlpha":true,"isOpaque":false,"minAlpha":0,"seeThrough":130391} png 7931 jpeg 6224
photo.png            {"hasAlpha":false,"isOpaque":true,"minAlpha":255,"seeThrough":0} png 4096344 jpeg 293649
photo-rgba.png       {"hasAlpha":true,"isOpaque":true,"minAlpha":255,"seeThrough":0} png 4349210 jpeg 293649
photo-one-pixel.png  {"hasAlpha":true,"isOpaque":false,"minAlpha":254,"seeThrough":1} png 4349221 jpeg 293649
screenshot.png       {"hasAlpha":false,"isOpaque":true,"minAlpha":255,"seeThrough":0} png 1121126 jpeg 169526
screen2.png          {"hasAlpha":true,"isOpaque":true,"minAlpha":255,"seeThrough":0} png 2683707 jpeg 119015
Enter fullscreen mode Exit fullscreen mode
File hasAlpha isOpaque PNG JPEG q80
transparent.png (512 × 512) true false 7.9 KB 6.2 KB
photo.png false true 4,096.3 KB 293.6 KB
photo-rgba.png true true 4,349.2 KB 293.6 KB
photo-one-pixel.png true false 4,349.2 KB 293.6 KB
screenshot.png false true 1,121.1 KB 169.5 KB
screen2.png true true 2,683.7 KB 119.0 KB

Bars: three opaque PNGs and the JPEG each became, photo.png 4,096.3 KB to 293.6 KB

Two rows matter. screen2.png has an alpha channel and a hasAlpha check would keep it as a 2,683.7 KB PNG. isOpaque says it is safe, and the JPEG is 119.0 KB. Then photo-one-pixel.png: one pixel out of 2,457,600 sits at alpha 254, and isOpaque flips to false.

Should one pixel at 254 keep a file as PNG?

That depends on your product. isOpaque is strict on purpose: it never guesses. A pixel at 254 is invisible on any background, so you may want a tolerance. The minAlpha and seeThrough fields give you one:

const safeAsJpeg = (o) => o.isOpaque || (o.minAlpha >= 250 && o.seeThrough < 100);
Enter fullscreen mode Exit fullscreen mode

Pick the numbers for your own files. A logo with soft anti-aliased edges has thousands of pixels between 1 and 254, so a tolerance like this one still keeps it as PNG.

What goes wrong if you convert without the check?

The transparent pixels turn black. I converted transparent.png to JPEG with no flatten() call and read the top-left pixel back:

source corner RGBA [ 42, 157, 143, 0 ]
jpeg corner RGB    [ 0, 0, 0 ]
Enter fullscreen mode Exit fullscreen mode

Note: sharp's flatten() merges alpha onto a background and its default background is black, {r: 0, g: 0, b: 0} (sharp flatten()). Writing JPEG from an RGBA input lands on the same black. Always pass a background you chose, even for files isOpaque approved, so a tolerance never paints a black speck.

How much does the check cost?

One stats() pass decodes the image once. Median of 5 runs:

File stats()
transparent.png (512 × 512) 39.5 ms
screen2.png (768 × 1376) 66.1 ms
photo-rgba.png (1920 × 1280) 82.9 ms

That is small next to a JPEG or AVIF encode of the same file. If you also count see-through pixels, the extractChannel pass decodes again; skip it when isOpaque is already true.

Where this fits in an upload pipeline

Run the check right after you sniff the format and before you pick an output. Opaque PNG photos and screenshots go to JPEG, WebP or AVIF. Files with real transparency stay PNG, or go to WebP or AVIF, which both keep alpha. The same rule decides when our PNG to JPG converter is the right tool and when a transparent logo should stay a PNG.

How I measured

  • Machine: Apple M1 Pro (8 cores), macOS, 2026-10-17.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, mozjpeg as bundled with sharp.
  • Files: photo.png, screenshot.png and transparent.png from the site's reference set (apps/web/assets/reference/), screen2.png from the engine fixtures. photo-rgba.png is photo.png through ensureAlpha(1); photo-one-pixel.png is that file with byte 3 set to 254.
  • JPEG: flatten({ background: '#ffffff' }).jpeg({ quality: 80, mozjpeg: true }).
  • Timings: performance.now() around stats(), median of 5 runs.

What I did not test

16-bit PNGs, palette PNGs that mark transparency with a tRNS chunk, and APNG. The tolerance numbers are an example, not a recommendation.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)