#1054·ds4

Progressive JPEG: AC refinement scan desynchronises mid-stream, ds4_image_decode_memory fails on files libjpeg decodes cleanly

Author: jaysuenoCreated Sep 15, 2026Updated Sep 15, 2026

Environment

  • ds4 commit 9139e2ae58a41503968a500f36f75895c1ba63fc (main, 2026-09-14)
  • macOS 26.5.2, Apple M4 Pro, 64 GB, Apple clang 21.0.0
  • No model involved: ds4_image.c compiled standalone, exactly as tests/test_jpeg_decode.py does.

Summary

A share of ordinary progressive JPEGs are rejected with invalid or unsupported JPEG image. libjpeg-turbo (djpeg) and Pillow decode the same bytes without a warning. The failure is inside the progressive AC refinement path (Ah != 0), mid-scan, with plenty of entropy data left.

On a set of 50 photographs from our own pipeline, 15 fail. Re-encoding the pixels at a different quality does not simply fix them: 10 of the 15 start decoding and 7 files that decoded before start failing. So the trigger is a property of the coefficient data, not of a particular camera or file.

Steps to reproduce

bash
cc -shared -fPIC -O2 ds4_image.c -lm -o image.so
python3 - <<'PY'
import ctypes
class Img(ctypes.Structure):
    _fields_ = [("width", ctypes.c_uint32), ("height", ctypes.c_uint32),
                ("rgb", ctypes.POINTER(ctypes.c_uint8)), ("fingerprint", ctypes.c_uint8 * 32)]
lib = ctypes.CDLL("./image.so")
lib.ds4_image_decode_memory.argtypes = [ctypes.POINTER(Img), ctypes.c_void_p, ctypes.c_size_t,
                                        ctypes.c_void_p, ctypes.c_size_t]
enc = open("ds4-progressive-refine-320x240-q85.jpg", "rb").read()
img, err = Img(), ctypes.create_string_buffer(256)
rc = lib.ds4_image_decode_memory(ctypes.byref(img), ctypes.create_string_buffer(enc, len(enc)),
                                 len(enc), err, len(err))
print("rc", rc, "err", err.value)
PY

The attached file is generated (drawn text over pseudo-random noise); it contains no photograph and no personal data. It was produced with nothing but:

bash
cjpeg -quality 85 -progressive -outfile out.jpg in.ppm     # libjpeg-turbo, default scan script

Roughly one in five images generated this way is refused, so the attachment is one instance of a class, not a hand-crafted edge case.

Generator for the reproducer class (no attachment needed; about one in five outputs is refused — try seeds 1..6):

python
# needs: pip install pillow numpy ; and libjpeg-turbo's cjpeg on PATH
import subprocess, numpy as np
from PIL import Image, ImageDraw
def make(w, h, seed):
    rng = np.random.default_rng(seed)
    img = Image.new("RGB", (w, h), (247, 245, 240)); d = ImageDraw.Draw(img); y = 4
    while y < h - 10:
        d.text((3, y), "SYNTHETIC 0123456789 ABCDEFG", fill=(12, 12, 16)); y += 12
    arr = np.array(img).astype(np.int16) + rng.integers(-18, 19, size=(h, w, 3))
    return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
for seed in range(1, 7):
    make(320, 240, seed).save(f"in{seed}.ppm")
    subprocess.run(["cjpeg", "-quality", "85", "-progressive", "-outfile", f"repro{seed}.jpg", f"in{seed}.ppm"], check=True)

Then run the ds4_image_decode_memory probe above on each repro{seed}.jpg; djpeg decodes all of them.

Expected

rc == 1, matching libjpeg:

bash
$ djpeg -pnm ds4-progressive-refine-320x240-q85.jpg | head -c 15
P6
320 240
255

Actual

rc 0 err b'invalid or unsupported JPEG image'

Where it goes wrong

Instrumenting a copy of third_party/iris/jpeg.h to log the scan header and the failing block:

SCAN 1  ss=0 se=0  ah=0 al=1  ncomp=3
SCAN 2  ss=1 se=5  ah=0 al=2  ncomp=1
SCAN 3  ss=1 se=63 ah=0 al=1  ncomp=1
SCAN 4  ss=1 se=63 ah=0 al=1  ncomp=1
SCAN 5  ss=6 se=63 ah=0 al=2  ncomp=1
SCAN 6  ss=1 se=63 ah=2 al=1  ncomp=1
SCAN 7  ss=0 se=0  ah=1 al=0  ncomp=3
SCAN 8  ss=1 se=63 ah=1 al=0  ncomp=1
SCAN 9  ss=1 se=63 ah=1 al=0  ncomp=1
SCAN 10 ss=1 se=63 ah=1 al=0  ncomp=1
FAIL ac_refine scan=10 by=9 bx=33 of 40x30 pos=3244/10514 eof=0 eobrun=0

Every scan before the last luma AC refinement decodes. In scan 10, jpeg_prog_decode_ac_refine() returns -1 after jpeg_decode_huffman() runs out of valid codes (third_party/iris/jpeg.h:391), with the bitstream only 31% consumed and eof clear — i.e. the reader has drifted out of alignment somewhere earlier in that scan rather than hitting the end of the data.

The same signature appears on real photographs (a 1200x1600 file fails at scan 8 of 8, Ss=1 Se=63 Ah=1 Al=0, block (116, 95) of 150x200, pos 73742/139400, eof=0).

What we ruled out

Rejected and accepted files are structurally identical: SOF2, 8-bit, 3 components, 4:2:0, no restart interval, 2 quantisation tables, 7 Huffman tables (max code length 16 bits in both groups), the same 8-scan script, the same 5 DHT segments after the first SOS, JFIF only, no fill bytes, no trailing bytes. jpegtran keeping the coefficients and re-emitting them baseline makes all 15 decode; re-emitting them progressive with libjpeg's default script leaves all 15 failing. So it is neither the container nor the scan script.

Two candidate areas in jpeg_prog_decode_ac_refine(), offered only as pointers:

  1. size != 1 is treated as fatal (return -1). libjpeg warns (JWRN_HUFF_BAD_CODE) and proceeds as if s == 1. That difference alone turns a recoverable stream into a hard failure.
  2. The correction-bit loop and the if (size == 1 && k <= dec->se) commit after it advance k in a slightly different order from libjpeg's do { … } while (k <= Se) plus the outer k++, which is where a one-coefficient drift would come from.

Happy to test a patch against the 50-file set and report back.