| Crates.io | zenjpeg |
| lib.rs | zenjpeg |
| version | 0.2.0 |
| created_at | 2025-12-27 23:33:21.715209+00 |
| updated_at | 2026-01-24 05:05:56.497164+00 |
| description | Pure Rust JPEG encoder/decoder with perceptual optimizations |
| homepage | |
| repository | https://github.com/imazen/zenjpeg |
| max_upload_size | |
| id | 2008032 |
| size | 4,754,953 |
A pure Rust JPEG encoder and decoder with perceptual optimizations.
Note: This crate was previously published as
jpegli-rs. If migrating, update your imports fromuse jpegli::touse zenjpeg::.
This project started as a port of jpegli, Google's improved JPEG encoder from the JPEG XL project. After six rewrites it has diverged significantly into an independent project.
Ideas adopted from jpegli:
Ideas adopted from mozjpeg:
Where we went our own way:
#![forbid(unsafe_code)] by default (unsafe SIMD is opt-in)wide crate instead of platform intrinsicswide crateultrahdr feature)use zenjpeg::encoder::{EncoderConfig, PixelLayout, ChromaSubsampling, Unstoppable};
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter);
let mut enc = config.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
enc.push_packed(&rgb_bytes, Unstoppable)?;
let jpeg_bytes: Vec<u8> = enc.finish()?;
Requires features = ["decoder"] (prerelease API).
use zenjpeg::decoder::Decoder;
let image = Decoder::new().decode(&jpeg_bytes)?;
let rgb_pixels: &[u8] = image.pixels();
let (width, height) = image.dimensions();
All encoder types are in zenjpeg::encoder:
use zenjpeg::encoder::{
EncoderConfig, PixelLayout, Quality, ChromaSubsampling, Unstoppable
};
use zenjpeg::encoder::{EncoderConfig, PixelLayout, ChromaSubsampling, Unstoppable};
// Create reusable config (quality and color mode set in constructor)
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter)
.progressive(true);
// Encode from raw bytes
let mut enc = config.encode_from_bytes(1920, 1080, PixelLayout::Rgb8Srgb)?;
enc.push_packed(&rgb_bytes, Unstoppable)?;
let jpeg = enc.finish()?;
| Method | Input Type | Use Case |
|---|---|---|
encode_from_bytes(w, h, layout) |
&[u8] |
Raw byte buffers |
encode_from_rgb::<P>(w, h) |
rgb crate types |
RGB<u8>, RGBA<f32>, etc. |
encode_from_ycbcr_planar(w, h) |
YCbCrPlanes |
Video decoder output |
use zenjpeg::encoder::{EncoderConfig, PixelLayout, ChromaSubsampling, Unstoppable};
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter);
// From raw RGB bytes
let mut enc = config.encode_from_bytes(800, 600, PixelLayout::Rgb8Srgb)?;
enc.push_packed(&rgb_bytes, Unstoppable)?;
let jpeg = enc.finish()?;
// From rgb crate types
use rgb::RGB;
let mut enc = config.encode_from_rgb::<RGB<u8>>(800, 600)?;
enc.push_packed(&pixels, Unstoppable)?;
let jpeg = enc.finish()?;
// From planar YCbCr (video pipelines)
let mut enc = config.encode_from_ycbcr_planar(1920, 1080)?;
enc.push(&planes, num_rows, Unstoppable)?;
let jpeg = enc.finish()?;
Choose one constructor based on desired color mode:
| Constructor | Color Mode | Use Case |
|---|---|---|
EncoderConfig::ycbcr(q, sub) |
YCbCr | Standard JPEG (most compatible) |
EncoderConfig::xyb(q, b_sub) |
XYB | Perceptual color space (better quality) |
EncoderConfig::grayscale(q) |
Grayscale | Single-channel output |
| Method | Description | Default |
|---|---|---|
.progressive(bool) |
Progressive JPEG (~3% smaller) | false |
.optimize_huffman(bool) |
Optimal Huffman tables | true |
.deringing(bool) |
Overshoot deringing for documents/graphics | true |
.sharp_yuv(bool) |
SharpYUV downsampling | false |
.separate_chroma_tables(bool) |
Use 3 quant tables (Y, Cb, Cr) vs 2 (Y, shared) | true |
.icc_profile(bytes) |
Attach ICC profile | None |
.exif(exif) |
Embed EXIF metadata | None |
.xmp(data) |
Embed XMP metadata | None |
.restart_interval(n) |
MCUs between restart markers | 0 |
use zenjpeg::encoder::{EncoderConfig, Quality, ChromaSubsampling};
// Simple quality scale (0-100)
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter);
// Quality enum variants
let config = EncoderConfig::ycbcr(
Quality::ApproxJpegli(85.0), // Default scale
ChromaSubsampling::Quarter
);
// Or: Quality::ApproxMozjpeg(80) - Match mozjpeg output
// Or: Quality::ApproxSsim2(90.0) - Target SSIMULACRA2 score
// Or: Quality::ApproxButteraugli(1.0) - Target butteraugli distance
| Layout | Bytes/px | Notes |
|---|---|---|
Rgb8Srgb |
3 | Default, sRGB gamma |
Bgr8Srgb / Bgrx8Srgb |
3/4 | Windows/GDI order |
Rgbx8Srgb |
4 | 4th byte ignored |
Gray8Srgb |
1 | Grayscale sRGB |
Rgb16Linear |
6 | 16-bit linear |
RgbF32Linear |
12 | HDR float (0.0-1.0) |
YCbCr8 / YCbCrF32 |
3/12 | Pre-converted YCbCr |
use zenjpeg::encoder::{EncoderConfig, ChromaSubsampling, XybSubsampling};
// YCbCr subsampling
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter); // 4:2:0 (best compression)
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::None); // 4:4:4 (best quality)
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::HalfHorizontal); // 4:2:2
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::HalfVertical); // 4:4:0
// XYB B-channel subsampling
let config = EncoderConfig::xyb(85, XybSubsampling::BQuarter); // B at 4:2:0
let config = EncoderConfig::xyb(85, XybSubsampling::Full); // No subsampling
use zenjpeg::encoder::{EncoderConfig, ChromaSubsampling};
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter);
// Typical memory estimate
let estimate = config.estimate_memory(1920, 1080);
// Guaranteed upper bound (for resource reservation)
let ceiling = config.estimate_memory_ceiling(1920, 1080);
Prerelease: The decoder API is behind the
decoderfeature flag and will have breaking changes. Enable withzenjpeg = { version = "...", features = ["decoder"] }.
All decoder types are in zenjpeg::decoder:
use zenjpeg::decoder::{Decoder, DecodedImage, DecodedImageF32, DecoderConfig};
// Decode to RGB (default)
let image = Decoder::new().decode(&jpeg_data)?;
let pixels: &[u8] = image.pixels();
let (width, height) = image.dimensions();
Preserves jpegli's 12-bit internal precision:
let image: DecodedImageF32 = Decoder::new().decode_f32(&jpeg_data)?;
let pixels: &[f32] = image.pixels(); // Values in 0.0-1.0
// Convert to 8-bit or 16-bit when needed
let u8_pixels: Vec<u8> = image.to_u8();
let u16_pixels: Vec<u16> = image.to_u16();
For video pipelines or re-encoding:
use zenjpeg::decoder::{Decoder, DecodedYCbCr};
let ycbcr: DecodedYCbCr = Decoder::new().decode_to_ycbcr_f32(&jpeg_data)?;
// Access Y, Cb, Cr planes directly (f32, range [-128, 127])
let info = Decoder::new().read_info(&jpeg_data)?;
println!("{}x{}, {} components", info.width, info.height, info.num_components);
| Method | Description | Default |
|---|---|---|
.output_format(fmt) |
Output pixel format | Rgb |
.fancy_upsampling(bool) |
Smooth chroma upsampling | true |
.block_smoothing(bool) |
DCT block edge smoothing | false |
.apply_icc(bool) |
Apply embedded ICC profile | true |
.max_pixels(n) |
Pixel count limit (DoS protection) | 100M |
.max_memory(n) |
Memory limit in bytes | 512 MB |
let image = Decoder::new().decode(&jpeg_data)?;
image.width() // Image width
image.height() // Image height
image.dimensions() // (width, height) tuple
image.pixels() // &[u8] pixel data
image.bytes_per_pixel() // Bytes per pixel for format
image.stride() // Bytes per row
use zenjpeg::decoder::{Decoder, DecoderConfig};
// Most users should use the builder methods instead:
let image = Decoder::new()
.fancy_upsampling(true)
.block_smoothing(false)
.apply_icc(true)
.max_pixels(100_000_000)
.max_memory(512 * 1024 * 1024)
.decode(&jpeg_data)?;
// Or construct DecoderConfig directly:
let config = DecoderConfig::default();
let decoder = Decoder::from_config(config);
| Image Size | Sequential | Progressive | Notes |
|---|---|---|---|
| 512x512 | 118 MP/s | 58 MP/s | Small images |
| 1024x1024 | 92 MP/s | 36 MP/s | Medium images |
| 2048x2048 | 87 MP/s | 46 MP/s | Large images |
| Quality | Seq Size | Prog Size | Prog Δ | Prog Slowdown |
|---|---|---|---|---|
| Q50 | 322 KB | 313 KB | -2.8% | 2.5x |
| Q70 | 429 KB | 416 KB | -3.0% | 2.0x |
| Q85 | 586 KB | 568 KB | -3.1% | 2.1x |
| Q95 | 915 KB | 887 KB | -3.1% | 2.2x |
Progressive produces ~3% smaller files at the same quality, but takes ~2x longer.
Recommendation:
| Decoder | Speed | Notes |
|---|---|---|
| zune-jpeg | 392 MP/s | Integer IDCT, AVX2 |
| jpeg-decoder | 120 MP/s | Integer IDCT |
| zenjpeg | 47 MP/s | f32 IDCT, 12-bit precision |
The decoder prioritizes precision over speed, matching C++ jpegli's 12-bit pipeline.
The EncodingTables API provides fine-grained control over quantization and zero-bias
tables for researching better encoding parameters.
use zenjpeg::encoder::{EncoderConfig, ChromaSubsampling};
use zenjpeg::encoder::tuning::{EncodingTables, ScalingParams, dct};
// Start from defaults and modify
let mut tables = EncodingTables::default_ycbcr();
// Scale a specific coefficient (component 0 = Y, k = coefficient index)
tables.scale_quant(0, 5, 1.2); // 20% higher quantization at position 5
// Or use exact quantization values (no quality scaling)
tables.scaling = ScalingParams::Exact;
tables.quant.c0[0] = 16.0; // DC quantization for Y
let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter)
.tables(Box::new(tables));
Quantization Tables (quant): 64 coefficients per component (Y/Cb/Cr or X/Y/B)
Zero-Bias Tables (zero_bias_mul, zero_bias_offset_*):
zero_bias_mul[k] multiplies the dead zone around zerozero_bias_offset_dc/ac add to the threshold before zeroingScaling Params:
ScalingParams::Scaled { global_scale, frequency_exponents } - quality-dependent scalingScalingParams::Exact - use raw values (must be valid u16 range)Position in 8x8 block (row-major index k):
0 1 2 3 4 5 6 7
8 9 10 11 12 13 14 15
16 17 18 19 20 21 22 23
24 25 26 27 28 29 30 31
32 33 34 35 36 37 38 39
40 41 42 43 44 45 46 47
48 49 50 51 52 53 54 55
56 57 58 59 60 61 62 63
k=0 is DC (average brightness)
k=1,8 are lowest AC frequencies (horizontal/vertical gradients)
k=63 is highest frequency (diagonal detail)
Use dct::freq_distance(k) to get Manhattan distance from DC (0-14).
Use dct::IMPORTANCE_ORDER for coefficients sorted by perceptual impact.
use zenjpeg::encoder::tuning::{EncodingTables, dct};
fn evaluate_tables(tables: &EncodingTables, corpus: &[Image]) -> f64 {
let mut total_score = 0.0;
for image in corpus {
let jpeg = encode_with_tables(image, tables);
let score = ssimulacra2_per_byte(&jpeg, image); // quality/size
total_score += score;
}
total_score / corpus.len() as f64
}
// Grid search over coefficient k
fn optimize_coefficient(k: usize, component: usize, corpus: &[Image]) {
let mut best_score = f64::MIN;
let mut best_value = 1.0;
for scale in [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] {
let mut tables = EncodingTables::default_ycbcr();
tables.scale_quant(component, k, scale);
let score = evaluate_tables(&tables, corpus);
if score > best_score {
best_score = score;
best_value = scale;
}
}
println!("Coefficient {} best scale: {}", k, best_value);
}
For automated discovery, use derivative-free optimizers:
// Using argmin crate with Nelder-Mead
use argmin::solver::neldermead::NelderMead;
fn objective(params: &[f64], corpus: &[Image]) -> f64 {
let mut tables = EncodingTables::default_ycbcr();
// Map params to table modifications (e.g., first 10 most impactful coefficients)
for (i, &scale) in params.iter().enumerate() {
let k = dct::IMPORTANCE_ORDER[i + 1]; // Skip DC
tables.scale_quant(0, k, scale as f32); // Y component
}
-evaluate_tables(&tables, corpus) // Negative because we minimize
}
Recommended optimizers:
Different image categories may benefit from different tables:
| Content Type | Strategy |
|---|---|
| Photographs | Lower DC/low-freq quant, preserve gradients |
| Graphics/UI | Higher high-freq quant, preserve edges |
| Text on photos | Balance - preserve both |
| Skin tones | Lower Cb/Cr quant in mid frequencies |
fn classify_and_encode(image: &Image) -> Vec<u8> {
let tables = match classify_content(image) {
ContentType::Photo => tables_optimized_for_photos(),
ContentType::Graphic => tables_optimized_for_graphics(),
ContentType::Mixed => EncodingTables::default_ycbcr(),
};
encode_with_tables(image, &tables)
}
Use quality metrics to weight optimization:
// SSIMULACRA2 weights certain frequencies more than others
// Butteraugli penalizes different artifacts
fn multi_metric_score(jpeg: &[u8], original: &Image) -> f64 {
let ssim2 = ssimulacra2(jpeg, original);
let butteraugli = butteraugli_distance(jpeg, original);
let size = jpeg.len() as f64;
// Combine: higher quality, lower butteraugli, smaller size
(ssim2 * 100.0 - butteraugli * 10.0) / (size / 1000.0)
}
use zenjpeg::encoder::tuning::dct;
// Coefficient analysis
dct::freq_distance(k) // Manhattan distance from DC (0-14)
dct::row_col(k) // (row, col) in 8x8 block
dct::to_zigzag(k) // Row-major to zigzag order
dct::from_zigzag(z) // Zigzag to row-major
dct::IMPORTANCE_ORDER // Coefficients by perceptual impact
// Table manipulation
tables.scale_quant(c, k, factor) // Scale one coefficient
tables.perturb_quant(c, k, delta) // Add delta to coefficient
tables.blend(&other, t) // Linear interpolation (0.0-1.0)
tables.quant.scale_component(c, f) // Scale entire component
tables.quant.scale_all(f) // Scale all coefficients
Enabled by default. This technique was pioneered by @kornel in mozjpeg and significantly improves quality for documents, screenshots, and graphics without any quality penalty for photographic content.
JPEG uses DCT (Discrete Cosine Transform) which represents pixel blocks as sums of cosine waves. Hard edges—like text on a white background—create high-frequency components that are difficult to represent accurately. The result is "ringing": oscillating artifacts that look like halos or waves emanating from sharp transitions.
JPEG decoders clamp output values to 0-255. This means to display white (255), any encoded value ≥255 works identically after clamping. The encoder can exploit this "headroom" above the displayable range.
Instead of encoding a flat plateau at the maximum value, deringing creates a smooth curve that "overshoots" above the maximum:
This is analogous to "anti-clipping" in audio processing.
Deringing is on by default. To disable it (not recommended):
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter)
.deringing(false); // Disable deringing
Tested against C++ jpegli on frymire.png (1118x1105):
| Metric | Rust | C++ | Difference |
|---|---|---|---|
| File size (Q85 seq) | 586.3 KB | 586.7 KB | -0.1% |
| File size (Q85 prog) | 568.2 KB | 565.1 KB | +0.5% |
| SSIM2 (Q85) | 69.0 | 69.0 | identical |
Quality is identical; file sizes within 0.5%.
When comparing output between zenjpeg and C++ jpegli, use jpegli_set_distance()
in C++, not jpeg_set_quality(). Here's why:
The issue:
jpeg_set_quality() in C++ uses 2 chroma tables (Cb and Cr share the same table)jpegli_set_distance() in C++ uses 3 tables (separate Y, Cb, Cr tables)Using jpeg_set_quality() for comparison will show ~4% file size differences and
different quantization behavior because the encoders are configured differently.
Correct comparison (FFI):
// C++ - use distance-based quality (3 tables)
jpegli_set_distance(&cinfo, 1.0, JPEGLI_TRUE); // distance 1.0 ≈ quality 90
// NOT: jpeg_set_quality(&cinfo, 90, TRUE); // 2 tables - invalid comparison!
Quality to distance conversion:
fn quality_to_distance(q: f32) -> f32 {
if q >= 100.0 { 0.01 }
else if q >= 30.0 { 0.1 + (100.0 - q) * 0.09 }
else { 53.0 / 3000.0 * q * q - 23.0 / 20.0 * q + 25.0 }
}
// q90 → distance 1.0, q75 → distance 2.35
With proper distance-based comparison, size and quality differences are typically within ±1%.
Matching jpeg_set_quality() behavior:
If you need output that matches tools using jpeg_set_quality() (2 tables),
use the .separate_chroma_tables(false) option:
// Match jpeg_set_quality() behavior (2 tables: Y, shared chroma)
let config = EncoderConfig::ycbcr(85, ChromaSubsampling::Quarter)
.separate_chroma_tables(false);
| Feature | Default | Description |
|---|---|---|
decoder |
No | Enable decoder API (prerelease, API will change) |
ultrahdr |
No | UltraHDR HDR gain map encoding/decoding (requires decoder) |
cms-lcms2 |
Yes | Color management via lcms2 |
cms-moxcms |
No | Pure Rust color management |
unsafe_simd |
No | Raw AVX2/SSE intrinsics (~10-20% faster) |
test-utils |
Yes | Testing utilities |
By default, the crate uses #![forbid(unsafe_code)]. SIMD is provided via the safe, portable wide crate. Enable unsafe_simd for raw intrinsics on x86_64.
[dependencies]
zenjpeg = "0.11"
# With UltraHDR support:
zenjpeg = { version = "0.11", features = ["ultrahdr"] }
# Minimal (no CMS):
zenjpeg = { version = "0.11", default-features = false }
# With unsafe SIMD (x86_64 only):
zenjpeg = { version = "0.11", features = ["unsafe_simd"] }
| Feature | Status |
|---|---|
| Baseline JPEG | Working |
| Progressive JPEG | Working |
| Adaptive quantization | Working |
| Huffman optimization | Working |
| 4:4:4 / 4:2:0 / 4:2:2 / 4:4:0 | Working |
| XYB color space | Working |
| Grayscale | Working |
| Custom quant tables | Working |
| ICC profile embedding | Working |
| YCbCr planar input | Working |
Prerelease: Enable with
features = ["decoder"]. API will have breaking changes.
| Feature | Status |
|---|---|
| Baseline JPEG | Working |
| Progressive JPEG | Working |
| All subsampling modes | Working |
| Restart markers | Working |
| ICC profile extraction | Working |
| XYB decoding | Working (with CMS) |
| f32 output | Working |
Profiling against C++ jpegli reveals these bottlenecks (2K image, progressive 4:2:0):
| Area | Rust | C++ | Gap | Notes |
|---|---|---|---|---|
| RGB→YCbCr | 11.7% | 1.7% | 6.9x | Biggest opportunity |
| Adaptive quantization | 28.6% | 12.1% | 2.4x | Algorithm efficiency |
| Huffman freq counting | 5.7% | 0.5% | 11x | Already SIMD, still slow |
| DCT | 7.3% | 5.5% | 1.3x | Reasonable |
| Entropy encoding | 10.9% | 35.9% | — | C++ slower here |
Crates to investigate for RGB→YCbCr:
yuv (0.8.9) - Faster than libyuv, AVX-512/AVX2/SSE/NEONyuvutils-rs - AVX2/SSE/NEON, optional AVX-512dcv-color-primitives - AWS, AVX2/NEONCurrent gap: Rust is ~1.6-1.9x slower than C++ jpegli (fair FFI comparison).
# Quick parity test (no C++ build needed)
cargo test --release --test cpp_parity_locked
# Full comparison (requires C++ jpegli built)
cargo test --release --test comprehensive_cpp_comparison -- --nocapture --ignored
git submodule update --init --recursive
cd internal/jpegli-cpp && mkdir -p build && cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DJPEGXL_ENABLE_TOOLS=ON ..
ninja cjpegli djpegli
AGPL-3.0-or-later
A commercial license is available from https://imageresizing.net/pricing
Originally a port of jpegli from the JPEG XL project by Google (BSD-3-Clause). After six rewrites, this is now an independent project that shares ideas but little code with the original.
Developed with assistance from Claude (Anthropic). Extensively tested against C++ reference with 340+ tests. Report issues at https://github.com/imazen/zenjpeg/issues