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.
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
};
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.
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();
}
};
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).
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 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).
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.
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.
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.
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.
0xFF4F.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),
};
}
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.
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.
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.
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 };
}
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;
}
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.
3 * num_levels + 1 at parse time avoids out-of-bounds reads in the QCD data.length bytes of body data instead of length - 2 consumes the first two bytes of the next marker, corrupting the parse state.