DEV Community

yue xing
yue xing

Posted on

What a progressive JPEG shows with only 10% of its bytes

My graduation project has a gallery page full of 2000-plus-pixel photos, and on the dorm Wi-Fi they load as a strip that crawls down from the top. A senior on my team said progressive JPEG would show the whole picture blurry first and sharpen it later. I wanted to see that for myself before converting anything. So I took a landscape photo from Wikimedia Commons, scaled it to 2736×1824, and saved it at quality 85 twice with Pillow 11.3 (libjpeg-turbo). One copy is baseline and one is progressive. The machine is an Apple M4 with 16 GB running macOS 26.5.2.

How I faked a half-finished download

My first plan was to throttle a local server and screenshot the page in a Chromium 149 open-source build. That didn't work. Every frame I captured came after the whole image had arrived, so I never saw the in-between state. I switched to a simulation instead. I kept only the first 10%, 25%, 50% or 75% of each file, piped it through djpeg from libjpeg-turbo 3.2.0, and compared the result with the source image using PSNR. This is a decoder cutting the file short. It is not a browser recording.

def score_prefix(jpeg_path, share):
    data = open(jpeg_path, "rb").read()
    cut = data[: int(len(data) * share)]
    ppm = subprocess.run(["djpeg", "-ppm"], input=cut, capture_output=True).stdout
    got = np.asarray(Image.open(io.BytesIO(ppm)), dtype=np.float64)
    return round(10 * math.log10(255 ** 2 / np.mean((got - ref) ** 2)), 2)
Enter fullscreen mode Exit fullscreen mode

djpeg complains when the data stops early, but it still writes a full-size image and fills the part it never got. ref is the source PNG loaded as a float array. An earlier run with Pillow's truncated-image mode gave exactly the same numbers, so the table below is repeatable on this machine.

Progressive is nearly done at half the bytes

Bytes received Baseline PSNR (dB) Progressive PSNR (dB)
10% 13.58 18.9
25% 14.65 22.73
50% 15.55 30.97
75% 18.16 34.07
100% 36.81 36.81

Truncated decodes of the landscape photo, baseline on top and progressive below

The top row is baseline and the bottom row is progressive, cut at 10%, 25%, 50% and 100% of the bytes from left to right. Look at the first column. The baseline file has only a strip at the top with the treetops in it, and everything below is a flat gray block. The progressive file already shows the whole scene, just soft. At 50% the baseline row still stops above the snow, about two thirds of the way down, while the progressive one is hard to tell apart from the last column. That matches the 30.97 dB in the table. The two files end at exactly the same 36.81 dB because they decode to the same pixels.

The number that surprised me was the baseline 75% row. It sits at 18.16 dB even though three quarters of the data is there. PSNR punishes the missing gray area so hard that the sharp top part barely counts.

What this simulation can't tell you

It says nothing about how many seconds earlier a real page looks usable. My screenshot attempt failed, and I didn't measure first paint, so I can't give you a number there. I also only tried one photo and one progressive scan layout, the default one in libjpeg-turbo. I haven't checked any of this in a released Safari build.

One more thing I ran into while picking test files. Most JPEGs around me were baseline anyway. canvas.toBlob in that Chromium build writes baseline. I used ImgIng (https://imging.ai/) in the same Chromium 149 build for two earlier JPG exports, and both headers say SOF0 as well. If you want the progressive behavior, you have to convert the file on a server or with a separate encoder.

If you have a large JPEG on hand, cut it to 25% with a few lines of Python and look at what your decoder draws. The difference is easier to see than to describe.

Top comments (0)