Image Format Processing (JPEG, PNG, GIF, CCITT)

Data Engineering & Processing advanced Building with AI ~30 min read

Overview

Image format decoding is a fundamental building block for any document processing system. Each image format represents a different set of trade-offs between compression ratio, quality, feature set, and decoding complexity. Understanding how these formats work at the byte level—not just as black-box codec calls—is essential when building parsers that need to extract images from container formats like PDF, handle corrupted or truncated data gracefully, or optimize for specific workloads.

The four formats covered here span the full spectrum of image compression: JPEG uses lossy Discrete Cosine Transform (DCT) compression optimized for photographs; PNG uses lossless deflate compression with per-row adaptive filtering; GIF uses LZW compression with palette-indexed color and animation support; and CCITT (Group 3/Group 4) uses run-length encoding optimized for black-and-white scanned documents. Each format appears frequently in PDFs, web content, and archival systems.

In a typical document processing pipeline, Zig-based implementations can provide decoders for all four formats as part of the PDF image extraction pipeline. These decoders are written in Zig to achieve zero-allocation hot paths, streaming decode without buffering entire images in memory, and predictable performance characteristics critical for processing document batches at scale.

Core Concepts

JPEG — DCT-Based Lossy Compression

JPEG compression works by dividing the image into 8×8 pixel blocks, converting each block from spatial domain to frequency domain using the Discrete Cosine Transform, quantizing the frequency coefficients (where lossy compression happens), and encoding the quantized values using Huffman or arithmetic coding. Baseline JPEG processes blocks in a single pass from top to bottom. Progressive JPEG encodes multiple passes—first a coarse approximation of the entire image, then successive refinement passes that add detail.

Key Concept: DCT Quantization

The DCT transforms an 8×8 pixel block into 64 frequency coefficients. The DC coefficient (index 0,0) represents the block’s average brightness, while higher-index AC coefficients represent increasingly fine detail. Quantization divides each coefficient by a value from a quantization table and rounds to the nearest integer. Higher quantization values discard more information—this is where JPEG achieves compression at the cost of quality. The quality setting (1–100) controls the quantization table values.

PNG — Lossless Deflate with Adaptive Filtering

PNG stores image data as a series of scanlines, each preceded by a filter byte that specifies how to preprocess the row before compression. The five filter types (None, Sub, Up, Average, Paeth) exploit spatial correlation between adjacent pixels to reduce entropy before the data is compressed with the DEFLATE algorithm (the same algorithm used in zlib/gzip). The encoder chooses the optimal filter per row to minimize compressed size.

Key Concept: Paeth Predictor

The Paeth filter predicts each byte as the value of one of three neighboring pixels (left, above, or upper-left) that is closest to the linear predictor p = left + above - upper_left. By choosing the neighbor closest to p, the Paeth filter adapts to horizontal edges, vertical edges, and diagonal gradients, often achieving better compression than simpler filters on photographic content within a lossless format.

GIF — LZW Compression with Palette Indexing

GIF uses LZW (Lempel-Ziv-Welch) compression on palette-indexed pixel data. Each GIF frame contains a color table of up to 256 colors, and each pixel is stored as an index into this table. GIF supports animation through multiple image frames with per-frame delay times, disposal methods (how to handle the previous frame when drawing the next), and optional transparency via a designated transparent color index. Despite its age, GIF remains widely used for simple animations.

Key Concept: LZW Dictionary Building

LZW compression builds a dictionary of byte sequences during both encoding and decoding. Starting with an initial dictionary of single-byte entries (0–255), the encoder finds the longest match in the dictionary for the current input, outputs its code, and adds a new entry for the matched sequence plus the next byte. The decoder reconstructs the same dictionary from the output codes. The special case where the decoder encounters a code not yet in its dictionary (the “KwKwK” problem) requires careful handling to avoid decoder errors.

CCITT — Run-Length Encoding for Bi-Level Images

CCITT Group 3 and Group 4 are compression standards designed by the ITU for fax transmission of black-and-white (bi-level) images. Group 3 encodes each scanline independently using a table of Huffman codes for run lengths of white and black pixels. Group 4 encodes each line relative to the previous line, achieving better compression by exploiting vertical correlation between rows. These formats appear frequently in scanned document PDFs.

Key Concept: Reference Line Coding (Group 4)

Group 4 CCITT encodes transitions (color changes) on the current line relative to transitions on the reference line (the previous decoded line). Three modes are used: Pass (a reference transition doesn’t correspond to a current-line change), Vertical (a current-line transition is within ±3 pixels of a reference transition), and Horizontal (encode absolute run lengths when vertical coding is too expensive). This relative encoding achieves 10–20× better compression than raw pixels for typical scanned documents.

How It Works

JPEG Baseline Decoding Pipeline

  1. Parse markers: SOI, APP0/APP1 (metadata), DQT (quantization tables), SOF0 (frame header), DHT (Huffman tables), SOS (scan header)
  2. For each 8×8 block in the scan: decode Huffman-coded coefficients, dequantize by multiplying with the quantization table
  3. Apply inverse DCT (IDCT) to convert frequency-domain coefficients back to spatial-domain pixel values
  4. If YCbCr color space: convert to RGB using the standard ITU-R BT.601 matrix
  5. Clamp values to [0, 255] and output the decoded pixel data

Zig JPEG Marker Parser

const std = @import("std");

const Marker = enum(u16) {
    soi  = 0xFFD8,  // Start of Image
    eoi  = 0xFFD9,  // End of Image
    sof0 = 0xFFC0,  // Baseline DCT
    sof2 = 0xFFC2,  // Progressive DCT
    dht  = 0xFFC4,  // Define Huffman Table
    dqt  = 0xFFDB,  // Define Quantization Table
    dri  = 0xFFDD,  // Define Restart Interval
    sos  = 0xFFDA,  // Start of Scan
    app0 = 0xFFE0,  // JFIF metadata
    app1 = 0xFFE1,  // EXIF metadata
    _,
};

const JpegReader = struct {
    data: []const u8,
    pos: usize = 0,

    pub fn nextMarker(self: *JpegReader) !Marker {
        // Skip any padding 0xFF bytes
        while (self.pos < self.data.len and self.data[self.pos] == 0xFF) {
            self.pos += 1;
        }
        if (self.pos >= self.data.len) return error.UnexpectedEof;

        const byte = self.data[self.pos];
        self.pos += 1;

        // 0x00 after 0xFF is a stuffed byte (escaped 0xFF in data), not a marker
        if (byte == 0x00) return error.StuffedByte;

        return @enumFromInt(@as(u16, 0xFF00) | @as(u16, byte));
    }

    pub fn readSegment(self: *JpegReader) ![]const u8 {
        if (self.pos + 2 > self.data.len) return error.UnexpectedEof;
        const len = std.mem.readInt(u16, self.data[self.pos..][0..2], .big);
        self.pos += 2;
        if (len < 2) return error.InvalidSegmentLength;
        const payload_len = len - 2;
        if (self.pos + payload_len > self.data.len) return error.UnexpectedEof;
        const segment = self.data[self.pos..self.pos + payload_len];
        self.pos += payload_len;
        return segment;
    }
};

Go PNG Filter Reconstruction

package png

// FilterType represents the PNG row filter applied before compression.
type FilterType byte

const (
    FilterNone    FilterType = 0
    FilterSub     FilterType = 1
    FilterUp      FilterType = 2
    FilterAverage FilterType = 3
    FilterPaeth   FilterType = 4
)

// ReconstructRow applies the inverse of the specified filter to produce
// the original pixel data for a scanline.
func ReconstructRow(filter FilterType, cur, prev []byte, bpp int) {
    switch filter {
    case FilterNone:
        // No transformation needed
    case FilterSub:
        for i := bpp; i < len(cur); i++ {
            cur[i] += cur[i-bpp]
        }
    case FilterUp:
        for i := 0; i < len(cur); i++ {
            cur[i] += prev[i]
        }
    case FilterAverage:
        for i := 0; i < len(cur); i++ {
            var left, above byte
            if i >= bpp {
                left = cur[i-bpp]
            }
            above = prev[i]
            cur[i] += byte((int(left) + int(above)) / 2)
        }
    case FilterPaeth:
        for i := 0; i < len(cur); i++ {
            var a, b, c byte // left, above, upper-left
            if i >= bpp {
                a = cur[i-bpp]
                c = prev[i-bpp]
            }
            b = prev[i]
            cur[i] += paethPredictor(a, b, c)
        }
    }
}

func paethPredictor(a, b, c byte) byte {
    p := int(a) + int(b) - int(c)
    pa := abs(p - int(a))
    pb := abs(p - int(b))
    pc := abs(p - int(c))
    if pa <= pb && pa <= pc {
        return a
    } else if pb <= pc {
        return b
    }
    return c
}

Practical Applications

PDF Image Extraction

When extracting images from PDFs, the parser encounters raw compressed data tagged with a filter name (/DCTDecode, /FlateDecode, /CCITTFaxDecode). The image decoder must handle each format, convert between color spaces (CMYK to RGB, indexed to direct color), and produce standardized output. In a typical document processing pipeline, extracted images are re-encoded to PNG for lossless storage or JPEG for size-optimized storage depending on the downstream use case.

Thumbnail Generation

Document preview systems need to generate thumbnails quickly from the first page of ingested documents. For JPEG images, progressive decoding enables generating a low-resolution preview from only the first scan pass, without decoding the full image. This trades quality for speed—a useful trade-off when generating hundreds of thumbnails per second in a batch pipeline.

Scanned Document Processing

Scanned PDFs store page images in CCITT Group 4 format for black-and-white scans or JPEG for grayscale/color. The decoding pipeline must identify the format, decode it, and pass the resulting raster image to an OCR engine for text extraction. Understanding the CCITT format is critical because many OCR-dependent pipelines fail silently when they cannot decode the image payload.

Implementation Patterns

Streaming Decode

Rather than loading the entire compressed image into memory before decoding, a streaming decoder processes data as it arrives. For JPEG, this means decoding MCU (Minimum Coded Unit) rows incrementally. For PNG, each decompressed scanline can be filtered and output before the next is decompressed. This pattern keeps peak memory proportional to image width rather than total image size.

Format Detection by Magic Bytes

Robust image processing starts with format identification. Each format has distinctive magic bytes:

// Zig: detect image format from first bytes
const ImageFormat = enum {
    jpeg,
    png,
    gif,
    ccitt_g4,
    unknown,
};

fn detectFormat(header: []const u8) ImageFormat {
    if (header.len >= 2 and header[0] == 0xFF and header[1] == 0xD8) return .jpeg;
    if (header.len >= 8 and std.mem.eql(u8, header[0..8],
        &[_]u8{ 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n' })) return .png;
    if (header.len >= 6 and (std.mem.eql(u8, header[0..6], "GIF87a") or
        std.mem.eql(u8, header[0..6], "GIF89a"))) return .gif;
    return .unknown;
}

SIMD-Accelerated IDCT

The inverse DCT is the most compute-intensive step in JPEG decoding. Modern decoders use SIMD instructions (SSE2, AVX2, NEON) to process multiple coefficients in parallel. The standard approach is the AAN (Arai, Agui, Nakajima) algorithm, which reduces the 2D 8×8 IDCT to row and column passes of 1D transforms, each requiring only 5 multiplications and 29 additions. Zig’s @Vector built-in maps directly to SIMD registers, making this optimization natural in the language.

Common Pitfalls

Further Reading