lib/fimg — streaming PNG / baseline-JPEG decoder and binarizer¶
lib/fimg turns an image file that arrives in arbitrary pieces (USB mass
storage sector writes, see ADR-0003 and lib-fmsc.md) into a bounded
1-bit-per-pixel bitmap for the QR decoder lib/fqr. It implements spec §3.3
(PNG and baseline JPEG only, ≤ 256 KiB, ≤ 1024 × 1024, streaming, bounded
memory, no full-frame RGBA buffer, clear error for unsupported variants) and
contributes to §10.4 and R05/R06 (safe rejection of hostile, truncated,
over-compressed and oversized images).
The file is never stored. Every byte is consumed as it arrives.
- Language: freestanding C99 (
<stdint.h>,<stddef.h>,<stdbool.h>,<string.h>only). No allocation, no recursion, no VLA, no floating point, no 64-bit division, no global mutable state. - Code size: about 18 KiB of Thumb-2 code (
arm-none-eabi-gcc -O2 -mcpu=cortex-m4), no data, no BSS. - Stack use depends on the scattered allocation plan. Measure with the ARM
compiler
-fstack-usage; initialization includes bounded plan/descriptor arrays. Do not infer a whole call-chain bound from one function frame. - Licence: GPL-3.0-only. All code is original. The inflater was written from RFC 1950/1951 and the IDCT follows the published LLM/islow algorithm (same constants as libjpeg). Nothing is vendored.
1. API in one screen¶
fimg_config_t cfg;
fimg_config_default(&cfg); /* 720 x 720 output, 256 KiB file cap */
size_t ws_size = fimg_workspace_size(&cfg); /* query the current ABI/configuration */
size_t bm_size = fimg_bitmap_size(&cfg); /* 64 800 B */
fimg_ctx_t *ctx;
fimg_init(&ctx, ws, ws_size, &cfg, bm, bm_size);
/* for every chunk, any length (1 byte .. whole file) */
st = fimg_feed(ctx, chunk, len); /* FIMG_OK or a sticky error */
/* when the file is complete (fmsc: stream_complete) */
fimg_info_t info;
st = fimg_finish(ctx, &info); /* FIMG_OK => bitmap complete */
/* bitmap: info.out_width x info.out_height, info.stride bytes per row,
* MSB first, bit = 1 is dark -> fqr_image_t { bm, w, h, stride } */
fimg_secure_zero(ws, ws_size); /* image of a secret: wipe */
fimg_secure_zero(bm, bm_size);
fimg_get_info() returns the format, source dimensions, scale factor and
output geometry as soon as the header has been parsed. Use it to reject a
file early or to show progress. info.bytes_consumed is the number of image
bytes up to and including IEND/EOI. fmsc can check it against the size in the
directory entry ("clean end within the reported size", ADR-0003).
Configuration (fimg_config_t):
| Field | Default | Meaning |
|---|---|---|
max_out_width, max_out_height |
720, 720 | Bitmap bound, 8..1024 each. |
max_out_pixels |
0 (= w·h) | Extra bound on the output pixel count. |
max_file_bytes |
262 144 | Image bytes allowed before the end marker. |
flags |
0 | FIMG_FLAG_STRICT_TRAILER, FIMG_FLAG_NO_PNG, FIMG_FLAG_NO_JPEG. |
luma_cb, luma_cb_user |
NULL | Full-resolution luma rows, for tests and diagnostics only. |
The default output of 720 × 720 keeps the bitmap at 64 800 B (≤ 64 KiB) with stride 90. A width of 724 would need stride 91 and 65 884 B.
2. Pipeline¶
bytes ─► sniff ─┬─► PNG: chunk parser + CRC-32 ─► inflate (32 KiB window) ─► unfilter (1 scanline) ─► luma row ─┐
└─► JPEG: staging (4 KiB) ─► markers / DHT / DQT ─► Huffman ─► dequant ─► islow IDCT ─► MCU band ─┤
▼
bitmap ◄─ adaptive binarizer (ring of K rows + column sums) ◄─ integer box downscaler (factor f)
2.1 Sniffing and byte accounting¶
The first bytes must be the PNG signature (8 bytes) or FF D8 FF (JPEG SOI
followed by a marker). Anything else gives FIMG_E_NOT_IMAGE on the first
mismatching byte. The signature bytes are then replayed into the selected
decoder.
fimg_feed passes at most max_file_bytes − bytes_consumed bytes to the
decoder. A stream that still needs bytes beyond the limit fails with
FIMG_E_FILE_TOO_LARGE. The limit counts image bytes up to and including
IEND / EOI.
Trailing-data policy. By default, bytes after IEND / EOI are discarded
unread and are not counted. MSC hosts write whole clusters, so the last
cluster normally carries padding past the end of the file. Only fmsc knows the
real file size (from the directory entry), so it bounds the stream.
FIMG_FLAG_STRICT_TRAILER turns any trailing byte into
FIMG_E_TRAILING_DATA.
2.2 PNG¶
- The chunk parser accepts any split of the input. Every chunk CRC-32 is
verified, ancillary chunks included, and a mismatch is fatal. IHDR, PLTE and
tRNS are buffered (≤ 768 B) and applied only after their CRC checks out.
IDAT data is inflated as it arrives. Its CRC is checked at the end of the
chunk, and a failure there still rejects the image before
fimg_finish. - Rules enforced:
- IHDR comes first and is exactly 13 bytes.
- Width and height must be in 1..1024, otherwise
FIMG_E_DIMENSIONS. - Colour type / bit depth must be one of: 0 with 1, 2, 4, 8 or 16; 2, 4 or 6
with 8 or 16; 3 with 1, 2, 4 or 8. Compression and filter methods must be
- Anything else gives
FIMG_E_PNG_UNSUPPORTED.
- Anything else gives
- Interlace 1 (Adam7) gives
FIMG_E_PNG_INTERLACED. - PLTE appears at most once, before IDAT, with 1..256 entries. It is required for type 3 and forbidden for types 0 and 4.
- IDAT chunks are contiguous.
- IEND is empty and closes the image.
- Chunk lengths are ≤ 2³¹−1 and chunk type bytes are ASCII letters.
- Unknown critical chunks give
FIMG_E_PNG_UNSUPPORTED. Unknown ancillary chunks are skipped. - An invalid tRNS (wrong length, position or colour type) is ignored, as an ancillary chunk may be.
- APNG.
acTL,fcTLandfdATare ancillary and skipped, so an APNG yields its default image (the IDAT stream). This is the frame that non-APNG viewers show. - Inflater (
fimg_inflate.c, written for feuristic): - Resumable at any byte. Stored, fixed and dynamic blocks; 32 KiB window that doubles as the output ring.
- Validation mirrors zlib, and the two are fuzzed differentially:
- CM = 8, CINFO ≤ 7, FCHECK, FDICT refused.
- The code-length code must be complete.
- Literal/length and distance codes must be complete, except a single 1-bit code.
- HLIT ≤ 286, HDIST ≤ 30, end-of-block code present, no repeat at the start, no repeat overflow.
- Symbols 286/287 and 30/31 are refused, as are distances before the start of the output.
- The Adler-32 trailer is checked.
- Decompression bomb. The inflated size must equal
height × (rowbytes + 1)exactly: - One byte more is refused as soon as it is produced
(
FIMG_E_PNG_DATA_SIZE), after the complete rows have been processed, so the error does not depend on chunking. - A shorter stream is refused when the zlib stream ends.
- Data after the end of the zlib stream gives
FIMG_E_PNG_CORRUPT. - A 60 MB zero bomb is refused after 272 bytes of output.
- Unfiltering. All five filter types are applied on two scanline buffers
of
rowbytes + 1bytes (≤ 8193 B). Filter type > 4 givesFIMG_E_PNG_CORRUPT. - Luma. Y = (19595 R + 38470 G + 7471 B + 32768) >> 16. This is Pillow's RGB→L fixed point.
- 16-bit samples use their high byte.
- 1/2/4-bit grey is scaled by 255, 85 or 17.
- Alpha is composited over white: Y' = round((Y·a + 255·(255−a)) / 255), exact integer.
- The tRNS colour key is compared on the raw sample (16-bit keys included), and a matching pixel becomes white.
- The palette luma is precomputed with tRNS alpha applied. Indices beyond PLTE are black.
2.3 JPEG¶
- Staging. All input goes through a 4 KiB staging buffer.
- Markers are parsed byte by byte: fill bytes, any segment length, DHT/DQT parsed incrementally (many tables per segment), SOF/SOS/DRI/APP0/APP14 headers buffered.
- An MCU is decoded only when the buffer holds at least 2560 bytes. This is
the worst case for one MCU of at most 6 blocks: (27 + 63 × 26) bits per
block, doubled for byte stuffing, plus prefetch. Alternatively, a marker
must already be visible. The Huffman hot loop therefore never suspends in
the middle of an MCU. Stuffed
FF 00pairs and markers may be split across chunks. - Accepted:
- SOF0, 8-bit precision.
- 1 component, or 3 components with luma H1V1, H2V1, H1V2 or H2V2 and chroma 1×1.
- Interleaved scans, and non-interleaved / multi-scan sequential files. The Y scan is decoded. Other scans are skipped by marker search, before or after the Y scan.
- Restart intervals, with RSTn checked in order.
- DQT with 8- or 16-bit precision, tables 0..3.
- DHT tables 0..1. Tables 2..3 are parsed and dropped, and are refused if a scan uses them.
- APPn and COM skipped. EXIF thumbnails inside APP1 are skipped with it.
- Colour model detection follows libjpeg:
- JFIF means YCbCr.
- Otherwise an Adobe transform 0 means RGB, and RGB is refused.
- Otherwise component ids 'R','G','B' mean RGB and are refused.
- Only luma is reconstructed. Chroma blocks of interleaved scans are Huffman-decoded and dropped, without dequantisation or IDCT.
- IDCT. libjpeg's
jidctint"islow" (LLM, 13-bit constants, 2 extra pass-1 bits). It is bit-exact with libjpeg-turbo (draft("L")in Pillow,djpeg -grayscale -dct int) on every corpus file: max luma difference 0, within the ±2 the spec allows. - Overflow guards. Dequantised coefficients and pass-1 outputs are clamped
to ±32767. Interval analysis of the butterfly then bounds every
intermediate by 0.94·2³¹, so a hostile stream cannot cause signed overflow
(UBSan runs in the tests and fuzzers). Valid 8-bit data never reaches the
clamps: |coef| ≤ 4096 and |pass 1| ≤ 23 170. A diagnostic counter
(
fimg_jpeg_t::sat) records any engagement; the differential fuzzer uses it. - Refused, each with its own code:
- SOF1 / precision ≠ 8 →
EXTENDED. - SOF2 →
PROGRESSIVE. - SOF3 / SOF55 (JPEG-LS) →
LOSSLESS. - SOF5..7, DHP, EXP →
HIERARCHICAL. - SOF9..15, DAC →
ARITHMETIC. - 2 or 4 components (CMYK/YCCK) and RGB →
COLOR. - Other sampling factors (4:1:1, chroma ≠ 1×1) →
SAMPLING. - A second SOF →
MULTIFRAME. - Height 0 (DNL) or a dimension > 1024 →
DIMENSIONS. - Structure errors give
FIMG_E_JPEG_CORRUPT: missing tables, bad lengths, wrong RSTn, garbage between segments, EOI before the image. - Entropy errors give
FIMG_E_JPEG_HUFFMAN: invalid code, DC category > 11, AC size > 10, run past coefficient 63, codes running into a marker. - Lenient points (as libjpeg):
- Padding between the end of a scan and the next marker is skipped.
- A Huffman table with an all-zero count is accepted, and fails only if it is used.
- An all-ones code is refused, as in libjpeg.
2.4 Downscaler¶
The factor f is the smallest integer such that ceil(w/f) ≤
max_out_width, ceil(h/f) ≤ max_out_height and the pixel product ≤
max_out_pixels. For example, 1024² gives f = 2 (512²) with the default
configuration, and 721 × 720 gives f = 2.
Each output pixel is the rounded mean of its f × f block. Edge blocks use
their real size. The downscaler keeps one u32 accumulator per output
column.
2.5 Adaptive binarizer¶
This is a single pass that adds no delay beyond r output rows. The window
radius is r = clamp(max(out_w, out_h) / 32, 4, 63), a window of about 1/16
of the image side (45 px at 720). A ring of K = 2r+1 output rows and running
per-column sums of v and round(v²/256) (u16 each) give the local mean
m and variance s² in O(1) per pixel.
For a pixel p:
| Condition | Result |
|---|---|
p < m − δ |
dark |
p > m + δ |
light |
window textured (s ≥ σmin) |
dark iff p < m |
| window flat | dark iff m ≤ lo + c/4 and m ≤ hi/2 (light if global contrast c < 48) |
lo and hi = lo + c are the class means of the iterative-intermeans
(ISODATA) threshold on the histogram of every row received so far.
δ = max(12, c/8) and σmin = max(8, c/16).
The flat rule keeps large uniform dark areas dark, such as finder-pattern centres of big modules (version-1 QR at 44 px/module in the corpus). It keeps shadowed paper light (photo-like renders with a 3× illumination gradient, blur, noise and JPEG artefacts). A uniformly dark image therefore binarizes to white. This is intended: no QR there.
Measured on the corpus (fimg_vectors):
- Clean renders: 100 % agreement with a fixed 128 threshold on the downscaled luma, ignoring edge pixels within 104..151. The worst clean render is 99.96 %.
- Every QR vector, including the degraded photos: 0 module errors when module centres are sampled.
The last r rows are flushed when the final source row arrives.
fimg_finish() only checks the end of the stream and returns.
3. Memory map¶
All memory comes from the caller. fimg_workspace_size(cfg) is the worst
case over every accepted image. It covers the larger of the two decoders,
because the format is unknown before the first bytes, unless
FIMG_FLAG_NO_PNG or FIMG_FLAG_NO_JPEG is set. Any alignment is accepted:
up to 8 bytes are skipped.
Default configuration (720 × 720), host build:
| Region | PNG | JPEG |
|---|---|---|
context (fimg_ctx_t: state, inflate tables, Huffman / quant tables, histogram) |
6 320 | 6 320 |
| decoder heap: LZ77 window | 32 768 | — |
| decoder heap: 2 scanlines (worst case 1024 × RGBA16 + filter byte) | 16 400 | — |
| decoder heap: luma row | 1 024 | — |
| decoder heap: JPEG staging buffer | — | 4 096 |
| decoder heap: MCU band (1024 × 16) | — | 16 384 |
| binarizer ring (45 rows × 720) | 32 400 | 32 400 |
| column sums + column square sums (u16) | 2 880 | 2 880 |
| downscaler accumulators (u32) | 2 880 | 2 880 |
| alignment slack | 8 | 8 |
| workspace total | 94 680 (92.5 KiB) | 64 968 (63.4 KiB) |
| output bitmap (separate buffer) | 64 800 | 64 800 |
Both totals meet the targets (≤ 96 KiB PNG, ≤ 64 KiB JPEG, excluding the bitmap). With the default flags the workspace is 94 680 B. Pointers are 4 bytes on Cortex-M, so the context is a little smaller there. With a 1024 × 1024 output the workspace is 131 272 B and the bitmap 131 072 B.
The optional scattered API addresses fragmentation without weakening the image
contract. fimg_scattered_sizes() reports at most sixteen caller-owned buffers,
each no larger than 16384 bytes, including the context in element zero.
fimg_init_scattered() validates eight-byte alignment, capacities and complete
non-overlap with the bitmap and descriptor array. The two DEFLATE window pages
retain the full 32 KiB history. Binarizer pages end at complete row boundaries.
The 720² scattered plan reserves 95760 bytes on this host ABI because a
smaller actual output width can require a larger full page than the maximum
configuration width; the unused capacity guarantees every accepted geometry.
The T2B1 384² plan is 69064 bytes on a 32-bit ABI, plus the separate 18432-byte
bitmap and the native caller's payload/README/session state. Initialization
performs no allocation: the caller retains ownership and must wipe/free every
buffer only after decoding finishes. See import RAM for
the hardware budget and remaining runtime qualification gate.
4. Performance¶
Measured on the host (Apple silicon, -O2, 512-byte feeds, decode +
downscale + binarize to 512²):
- 1024² 4:4:4/4:2:0 JPEG of 236 KB: 6.1 ms.
- 1000² 4:2:0 q90: 5.2 ms.
- 1024² grey PNG: 6.8 ms.
- Synthetic worst-case PNG, 1024² RGBA16 (8 MiB inflated from 53 KB): 38 ms under ASan.
Cortex-M4F @ 168 MHz (T2B1) estimate. No cycle-accurate simulator was used; these figures are derived from operation counts.
- JPEG. The 256 KiB cap bounds the entropy data to about 2.1 M bits.
- Huffman: ≤ about 700 k symbols at about 30 cycles each, ≤ 21 M cycles.
- IDCT: 16 384 luma blocks at about 700 cycles, 11 M cycles.
- Downscale: 1 M pixels at about 6 cycles, 6 M cycles.
- Binarizer: 262 k output pixels at about 40 cycles, 10 M cycles.
- Staging copies: 1 M cycles.
- Total: about 50 M cycles, about 0.3 s.
- Applying a conservative 130× host→M4 ratio to the measured 6 ms gives 0.8 s.
- PNG worst case (1024² RGBA16, 8 MiB of scanlines):
- Inflate copy: about 6 cycles/byte, 50 M cycles.
- Paeth unfilter: ≤ 15 cycles/byte, 125 M cycles.
- Luma: 20 M cycles.
- Total: about 1.2 s.
- A typical 8-bit grey or 1-bit QR PNG takes about 0.2 s.
- All within the < 3 s target.
- The STM32U585 (M33 @ 160 MHz) is comparable.
- There are no 64-bit divisions. The binarizer's variance test uses 32×32→64 multiplies (UMULL).
5. Status codes and user-facing messages¶
fimg_status_category() maps every code to one of eight messages. The
importer UI should show the category message. fimg_status_name() gives a
stable identifier for logs.
| Category | Codes | EN | FR |
|---|---|---|---|
NOT_IMAGE |
NOT_IMAGE |
Not a PNG or JPEG image. | Ce fichier n'est pas une image PNG ou JPEG. |
FILE_TOO_LARGE |
FILE_TOO_LARGE |
Image file larger than 256 KiB. | Fichier image trop volumineux (256 Kio max.). |
DIMENSIONS |
DIMENSIONS |
Image too large (max. 1024 × 1024 pixels). | Image trop grande (1024 × 1024 pixels max.). |
PNG_UNSUPPORTED |
PNG_INTERLACED, PNG_UNSUPPORTED |
Unsupported PNG (interlaced or unusual format). Save it again as a standard PNG. | PNG non pris en charge (entrelacé ou format inhabituel). Réenregistrez-le en PNG standard. |
JPEG_UNSUPPORTED |
JPEG_PROGRESSIVE, JPEG_EXTENDED, JPEG_LOSSLESS, JPEG_ARITHMETIC, JPEG_HIERARCHICAL, JPEG_COLOR, JPEG_SAMPLING, JPEG_MULTIFRAME |
Unsupported JPEG (progressive, CMYK, 12-bit…). Save it as a baseline JPEG or as PNG. | JPEG non pris en charge (progressif, CMJN, 12 bits…). Réenregistrez-le en JPEG « baseline » ou en PNG. |
CORRUPT |
PNG_CRC, PNG_CORRUPT, PNG_ZLIB, PNG_DATA_SIZE, JPEG_CORRUPT, JPEG_HUFFMAN, TRAILING_DATA |
Damaged image file. | Fichier image endommagé. |
TRUNCATED |
TRUNCATED |
Incomplete image file. Copy it again. | Fichier image incomplet. Copiez-le à nouveau. |
INTERNAL |
PARAM, WORKSPACE, STATE, LIMIT, INTERNAL |
Internal error while reading the image. | Erreur interne lors de la lecture de l'image. |
The specific code may be shown as a detail line. For example, for progressive JPEGs: "Progressive JPEG: not supported" / « JPEG progressif : non pris en charge ». Spec §3.3 asks for an understandable message when the file is too large or the JPEG variant is unsupported.
FIMG_E_LIMIT is reserved for internal limits. It is unreachable with a
validated configuration (scale factor search).
6. Limits and known behaviour¶
- Grey levels only. Colour is reduced to BT.601 luma, so a QR printed in a colour whose luma matches the background is invisible. That is inherent to QR scanning.
- Pillow (11–13) mishandles tRNS for 1/2/4-bit greyscale: it compares the key with the scaled sample. fimg follows the PNG specification (raw sample). The corpus reference follows the specification.
- An Adobe APP14 with transform 0, or 'R','G','B' component ids, is refused as RGB. Other 3-component files are treated as YCbCr, as libjpeg does.
- Out-of-specification coefficient streams make fimg clamp where libjpeg wraps. Their output is undefined in both decoders.
- EOI is required (
TRUNCATEDotherwise), even when all image data has been received. This is the strict reading of R06. - An all-dark or flat image gives an all-light bitmap. See §2.5.
7. Tests and fuzzing¶
tests/c/fimg/gen_vectors.py(Pillow + segno) regenerates the corpus intests/c/fimg/vectors/: 286 entries and 2.1 MB, withmanifest.txtdescribing each. It contains:- PNGs of every colour type and bit depth, and of every filter type at three pixel sizes.
- PNGs with stored, fixed, Huffman-only, RLE and small-window deflate, and with 1-byte and prime-split IDATs.
- PNGs with palette + tRNS, gray/RGB 8/16-bit colour keys, an invalid tRNS, a short palette, ancillary chunks and APNG.
- 44 broken or unsupported PNGs.
- JPEGs from Pillow: grey, 4:4:4, 4:2:2 and 4:2:0 at sizes from 1 × 1 to 130 × 7, quality 1–100, optimised tables, restart markers (blocks and rows), EXIF and COM segments.
- JPEGs from an in-house baseline encoder: 4:4:0, 16-bit DQT, combined DHT/DQT, fill bytes, separate and chroma-first scans, grey declared 2×2, no APP0 with a large COM, a restart every MCU.
- 46 broken or unsupported JPEGs.
- 67 raw zlib streams, of which 23 are invalid or truncated (hand-crafted).
- 13 QR renders, from clean 1-bit to degraded photos and 1024² backups.
fimg_vectorsdecodes every file with 5 chunkings and requires identical results: whole file, 1 byte, a prime cycle {7, 13, 251, 4093, 3, 1, 509}, 64-byte USB packets and pseudo-random sizes. It checks the status, the luma (exact for PNG, ±2 for JPEG; observed 0), luma CRCs, and QR agreement / module errors. It also decodes every file with a 100 × 90 / 7000-pixel configuration. Zlib streams run through the inflater with 1-byte input and 100-byte output limits.fimg_unitcovers:- API misuse, sticky errors, workspace and bitmap sizing, misaligned workspaces, status names and categories.
- Checksums, and exhaustive
div255. - Inflater suspension (1 input byte, 7 output bytes per call).
- Binarizer: white, black, checkerboard, a large dark square, dots on a gradient.
- Eleven scale-factor cases.
- The file-size limit (exact, ±1, trailing data, strict mode, 300 KiB ancillary chunk).
- Worst-case PNGs: 1024² RGBA16, an exact +1-byte bomb, a 60 MB bomb.
- Maximal JPEG COM segments larger than the staging buffer.
- Fuzzing: see
tests/fuzz/README-fimg.md.