JPEG2000 Codestream & Tile Decoder

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

Overview

JPEG2000 (ISO 15444-1) is a wavelet-based image compression standard that offers superior compression ratios and quality scaling compared to traditional JPEG. Unlike JPEG’s block-based DCT approach, JPEG2000 uses a discrete wavelet transform (DWT) that avoids blocking artifacts and supports lossless compression, region-of-interest decoding, and progressive resolution rendering—all from a single codestream.

The codestream is the binary format that carries all compressed image data. It begins with a Start of Codestream (SOC) marker and ends with End of Codestream (EOC), with everything in between organized into marker segments that describe the image geometry, quantization parameters, coding style, and the actual compressed tile-part data. A decoder must parse these markers in order to derive the tile grid, build quantization tables, and set up the context for entropy decoding of individual code blocks.

The codec library implements a JPEG2000 decoder in Zig for use in document processing pipelines where PDF pages embed JP2 images. This training document covers the codestream parsing stage: reading markers, computing the tile grid from SIZ parameters, constructing quantization tables from QCD/QCC segments, and organizing code block metadata into tile parts ready for wavelet reconstruction.

Core Concepts

Marker Segment Structure

A JPEG2000 codestream is a sequence of marker segments. Each marker is a two-byte value starting with 0xFF, followed by a marker-specific byte. Some markers (like SOC and EOC) are standalone; most are followed by a two-byte length field and then the marker body. The decoder reads markers sequentially, dispatching to the appropriate handler based on the marker type.

// Core JPEG2000 marker types
const Marker = enum(u16) {
    SOC = 0xFF4F, // Start of codestream
    SIZ = 0xFF51, // Image and tile size
    COD = 0xFF52, // Coding style default
    COC = 0xFF53, // Coding style component
    QCD = 0xFF5C, // Quantization default
    QCC = 0xFF5D, // Quantization component
    SOT = 0xFF90, // Start of tile-part
    SOD = 0xFF93, // Start of data
    EOC = 0xFFD9, // End of codestream
    RGN = 0xFF5E, // Region of interest
    POC = 0xFF5F, // Progression order change
    PLM = 0xFF57, // Packet length (main header)
    PLT = 0xFF58, // Packet length (tile-part)
    PPM = 0xFF60, // Packed packet headers (main)
    PPT = 0xFF61, // Packed packet headers (tile-part)
    COM = 0xFF64, // Comment
};
Key Concept: Marker Segment Parsing Order

The SIZ marker must appear immediately after SOC and before any other marker. It establishes the image dimensions, tile grid, number of components, and bit depth—all parameters that subsequent markers (COD, QCD) depend on. A decoder that encounters COD before SIZ cannot interpret the coding parameters because the number of components is unknown.

Image and Tile Geometry (SIZ)

The SIZ marker defines the reference grid: the image origin, image dimensions, tile origin, tile dimensions, number of components, and per-component sub-sampling factors. From these parameters, the decoder computes the tile grid—the number of tiles in X and Y directions and each tile’s pixel boundaries within the reference grid.

const SizParams = struct {
    x_siz: u32,       // Image width on reference grid
    y_siz: u32,       // Image height on reference grid
    xo_siz: u32,      // Horizontal offset of image origin
    yo_siz: u32,      // Vertical offset of image origin
    xt_siz: u32,      // Tile width
    yt_siz: u32,      // Tile height
    xto_siz: u32,     // Horizontal offset of first tile origin
    yto_siz: u32,     // Vertical offset of first tile origin
    num_components: u16,
    components: []ComponentInfo,

    pub fn numTilesX(self: SizParams) u32 {
        return ceilDiv(self.x_siz - self.xto_siz, self.xt_siz);
    }

    pub fn numTilesY(self: SizParams) u32 {
        return ceilDiv(self.y_siz - self.yto_siz, self.yt_siz);
    }

    pub fn totalTiles(self: SizParams) u32 {
        return self.numTilesX() * self.numTilesY();
    }
};
Key Concept: Reference Grid vs. Actual Pixels

The reference grid is an abstract coordinate space. Individual components may be sub-sampled—a YCbCr image might have luma at full resolution but chroma at half resolution. The SIZ marker records per-component sub-sampling factors (XRsiz, YRsiz) so the decoder can compute each component’s actual pixel dimensions as ceil(image_size / subsample_factor).

Coding Style (COD/COC)

The COD marker sets default coding parameters for all components: the number of decomposition levels (DWT depth), the code block dimensions, the wavelet transform type (reversible 5/3 or irreversible 9/7), and the progression order (LRCP, RLCP, RPCL, PCRL, CPRL). The COC marker can override these defaults for individual components.

Quantization (QCD/QCC)

Quantization maps continuous wavelet coefficient values to discrete integer indices. The QCD marker defines quantization step sizes for each subband. In lossless mode (reversible transform), no quantization is applied and the QCD marker carries only the number of guard bits. In lossy mode, each subband gets a mantissa-exponent pair that determines the step size: step = (1 + mantissa/2048) * 2^(guard_bits - exponent).

Key Concept: Subbands and Decomposition Levels

Each DWT decomposition level produces three detail subbands (HL, LH, HH) and one approximation subband (LL). The LL subband feeds into the next decomposition level. For N decomposition levels, the total number of subbands is 3N + 1. Each subband has its own quantization step size, and the QCD marker must provide parameters for all of them.

Tile-Parts and Start of Data (SOT/SOD)

Each tile’s compressed data is wrapped in one or more tile-parts. The SOT marker identifies the tile index, tile-part index, and the length of the tile-part data. The SOD marker immediately follows and marks the beginning of the actual bitstream. Between SOT and SOD, optional tile-specific markers (COC, QCC, POC) can override main header defaults for that tile.

Key Concept: Multiple Tile-Parts

A single tile can be split across multiple tile-parts, each with its own SOT/SOD pair. This enables streaming: a server can send tile-parts progressively, and the decoder can begin rendering a tile before receiving all its data. The tile-part index in SOT tracks which parts have been received.

How It Works

The decoding pipeline processes the codestream in two phases: header parsing and tile-part decoding. During header parsing, the decoder reads all main header markers (SOC through the first SOT) and builds the global context. During tile-part decoding, it processes each SOT/SOD pair, applying any tile-specific overrides before passing the bitstream to the entropy decoder.

  1. Read SOC: Verify the codestream starts with 0xFF4F.
  2. Read SIZ: Extract image dimensions, tile grid parameters, and component info.
  3. Compute tile grid: Calculate the number of tiles and each tile’s bounds.
  4. Read COD: Parse default coding style (decomposition levels, code block size, wavelet type).
  5. Read QCD: Parse default quantization parameters for each subband.
  6. Read optional markers: COC, QCC, RGN, POC, PLM, PPM, COM.
  7. For each tile-part: Read SOT, apply tile-level overrides, read SOD, extract bitstream.
  8. Organize code blocks: Within each tile, map the bitstream to precincts, packets, and code blocks.
const Decoder = struct {
    siz: SizParams,
    cod: CodParams,
    qcd: QcdParams,
    tiles: []TileState,
    allocator: std.mem.Allocator,

    pub fn decode(self: *Decoder, stream: *BitReader) !Image {
        // Phase 1: Main header
        try self.expectMarker(stream, .SOC);
        try self.parseSIZ(stream);
        self.tiles = try self.allocator.alloc(
            TileState,
            self.siz.totalTiles(),
        );

        // Read remaining main header markers
        while (true) {
            const marker = try stream.readMarker();
            switch (marker) {
                .COD => try self.parseCOD(stream),
                .QCD => try self.parseQCD(stream),
                .COC => try self.parseCOC(stream),
                .QCC => try self.parseQCC(stream),
                .COM => try self.skipMarker(stream),
                .SOT => break, // End of main header
                else => try self.skipMarker(stream),
            }
        }

        // Phase 2: Tile-parts
        while (true) {
            const tile_part = try self.parseSOT(stream);
            try self.applyTileOverrides(stream, tile_part.tile_index);
            try self.expectMarker(stream, .SOD);
            try self.decodeTilePart(stream, tile_part);

            const next = try stream.readMarker();
            if (next == .EOC) break;
            // next must be another SOT
        }

        return self.reconstructImage();
    }
};
// Computing tile boundaries from SIZ parameters
fn computeTileBounds(siz: SizParams, tile_index: u32) TileBounds {
    const num_x = siz.numTilesX();
    const tx = tile_index % num_x;
    const ty = tile_index / num_x;

    return TileBounds{
        .x0 = @max(siz.xto_siz + tx * siz.xt_siz, siz.xo_siz),
        .y0 = @max(siz.yto_siz + ty * siz.yt_siz, siz.yo_siz),
        .x1 = @min(siz.xto_siz + (tx + 1) * siz.xt_siz, siz.x_siz),
        .y1 = @min(siz.yto_siz + (ty + 1) * siz.yt_siz, siz.y_siz),
    };
}

Practical Applications

PDF Document Rendering

PDF 1.5+ supports JPEG2000 (JPX) as an image filter via the JPXDecode filter. When a PDF renderer encounters a JPX-encoded image XObject, it must decode the JPEG2000 codestream to extract pixel data for compositing onto the page. The codec library’s decoder handles this path, parsing the JP2 file format wrapper (which adds color space metadata) and then decoding the embedded codestream.

Medical Imaging (DICOM)

The DICOM standard uses JPEG2000 lossless compression for medical images where diagnostic quality must be preserved exactly. The tile-based architecture is particularly valuable here: a radiologist viewing a 50,000 × 50,000 pixel whole-slide image only needs tiles within the current viewport, enabling region-of-interest decoding without decompressing the entire image.

Geospatial Imagery

GeoTIFF and GMLJP2 use JPEG2000 for satellite and aerial imagery. The progressive resolution capability allows a GIS application to fetch a low-resolution overview first, then request higher-resolution tile-parts as the user zooms in. The codestream’s progression order (RPCL) is designed specifically for this use case.

Implementation Patterns

Streaming Marker Parser

Rather than loading the entire codestream into memory, an efficient decoder reads markers from a streaming reader. Each marker handler consumes exactly the number of bytes specified in the marker’s length field, advancing the reader position. This allows decoding of codestreams larger than available memory—essential for large-format imagery.

fn readMarkerSegment(reader: *BitReader) !MarkerSegment {
    const marker_byte = try reader.readU16Big();
    const marker = @intToEnum(Marker, marker_byte);

    // Standalone markers have no length field
    if (marker == .SOC or marker == .SOD or marker == .EOC) {
        return MarkerSegment{ .marker = marker, .data = &.{} };
    }

    // Read length (includes the 2 bytes of the length field itself)
    const length = try reader.readU16Big();
    const body_len = length - 2;
    const data = try reader.readBytes(body_len);

    return MarkerSegment{ .marker = marker, .data = data };
}

Quantization Table Construction

The QCD marker body encodes quantization parameters differently for reversible (lossless) and irreversible (lossy) transforms. For reversible transforms, each subband gets a single exponent byte. For irreversible transforms, each subband gets a 16-bit value encoding both mantissa and exponent. The decoder must construct the full quantization table before entropy decoding begins.

fn buildQuantTable(
    qcd_body: []const u8,
    num_subbands: u32,
    quant_style: QuantStyle,
) ![]f64 {
    var table = try allocator.alloc(f64, num_subbands);

    switch (quant_style) {
        .no_quantization => {
            // Reversible: exponent only, no quantization applied
            for (0..num_subbands) |i| {
                const exp = qcd_body[1 + i] >> 3;
                table[i] = 1.0; // Identity step
                _ = exp; // Used for bit-shifting during reconstruction
            }
        },
        .scalar_derived => {
            // Single base step, derived for all subbands
            const raw = std.mem.readIntBig(u16, qcd_body[1..3]);
            const mantissa = raw & 0x7FF;
            const exponent = raw >> 11;
            const base_step = @intToFloat(f64, 1 + mantissa) /
                2048.0 * std.math.pow(f64, 2.0, @intToFloat(f64, guard_bits - exponent));

            for (0..num_subbands) |i| {
                const level = subbandLevel(i);
                table[i] = base_step / std.math.pow(f64, 2.0, @intToFloat(f64, level));
            }
        },
        .scalar_expounded => {
            // Explicit step for each subband
            for (0..num_subbands) |i| {
                const offset = 1 + i * 2;
                const raw = std.mem.readIntBig(u16, qcd_body[offset..][0..2]);
                const mantissa = raw & 0x7FF;
                const exponent = raw >> 11;
                table[i] = @intToFloat(f64, 1 + mantissa) /
                    2048.0 * std.math.pow(f64, 2.0, @intToFloat(f64, guard_bits - exponent));
            }
        },
    }
    return table;
}

Tile State Machine

Each tile maintains state across potentially multiple tile-parts. The tile state tracks which packets have been received, the current bitstream position, and any tile-specific coding or quantization overrides. When a new tile-part arrives, the decoder appends its data to the existing tile state rather than replacing it.

Common Pitfalls

Further Reading