use butteraugli::{compute_butteraugli, ButteraugliParams};
use dssim::Dssim;
use jpegli::encoder::{ChromaSubsampling, EncoderConfig, PixelLayout};
use rgb::RGBA8;
use std::collections::HashMap;
#[derive(Clone, Debug)]
struct TestEncodingConfig {
quality: u8,
subsampling: ChromaSubsampling,
progressive: bool,
name: String,
}
fn encode_rgb(
width: u32,
height: u32,
data: &[u8],
config: &EncoderConfig,
) -> jpegli::encoder::Result<Vec<u8>> {
let mut enc = config.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
enc.push_packed(data, enough::Unstoppable)?;
enc.finish()
}
struct DecoderResult {
decoder_name: String,
pixels: Vec<u8>,
width: usize,
height: usize,
decode_time_us: u64,
}
fn max_butteraugli_for_quality(quality: u8) -> f64 {
match quality {
95..=100 => 1.5, 90..=94 => 2.0, 80..=89 => 2.5, 70..=79 => 3.5, 50..=69 => 5.0, _ => 8.0, }
}
const MAX_PIXEL_DIFF: u8 = 4;
const MAX_INTER_DECODER_DSSIM: f64 = 0.0005;
fn rgb_to_rgba(data: &[u8]) -> Vec<RGBA8> {
data.chunks(3)
.map(|c| RGBA8::new(c[0], c[1], c[2], 255))
.collect()
}
fn compute_dssim(a: &[u8], b: &[u8], width: usize, height: usize) -> f64 {
let attr = Dssim::new();
let a_rgba = rgb_to_rgba(a);
let b_rgba = rgb_to_rgba(b);
let a_img = attr.create_image_rgba(&a_rgba, width, height).unwrap();
let b_img = attr.create_image_rgba(&b_rgba, width, height).unwrap();
let (dssim, _) = attr.compare(&a_img, b_img);
dssim.into()
}
fn compute_butteraugli_score(original: &[u8], decoded: &[u8], width: usize, height: usize) -> f64 {
let params = ButteraugliParams::default();
match compute_butteraugli(original, decoded, width, height, ¶ms) {
Ok(result) => result.score,
Err(_) => 99.0, }
}
fn max_pixel_diff(a: &[u8], b: &[u8]) -> u8 {
a.iter()
.zip(b.iter())
.map(|(&x, &y)| x.abs_diff(y))
.max()
.unwrap_or(0)
}
fn decode_jpegli(data: &[u8]) -> Option<DecoderResult> {
let start = std::time::Instant::now();
let decoder = jpegli::decoder::Decoder::new();
match decoder.decode(data) {
Ok(img) => Some(DecoderResult {
decoder_name: "jpegli-rs".to_string(),
pixels: img.data,
width: img.width as usize,
height: img.height as usize,
decode_time_us: start.elapsed().as_micros() as u64,
}),
Err(e) => {
eprintln!("jpegli-rs decode failed: {}", e);
None
}
}
}
fn decode_zune_jpeg(data: &[u8]) -> Option<DecoderResult> {
use zune_jpeg::zune_core::bytestream::ZCursor;
use zune_jpeg::JpegDecoder;
let start = std::time::Instant::now();
let cursor = ZCursor::new(data);
let mut decoder = JpegDecoder::new(cursor);
match decoder.decode() {
Ok(pixels) => {
let (width, height) = decoder.dimensions().unwrap();
Some(DecoderResult {
decoder_name: "zune-jpeg".to_string(),
pixels,
width,
height,
decode_time_us: start.elapsed().as_micros() as u64,
})
}
Err(e) => {
eprintln!("zune-jpeg decode failed: {:?}", e);
None
}
}
}
fn generate_test_image(width: usize, height: usize) -> Vec<u8> {
let mut pixels = vec![0u8; width * height * 3];
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) * 3;
pixels[idx] = (x * 255 / width) as u8;
pixels[idx + 1] = (y * 255 / height) as u8;
pixels[idx + 2] = ((x + y) * 127 / (width + height)) as u8;
}
}
pixels
}
fn generate_complex_test_image(width: usize, height: usize) -> Vec<u8> {
let mut pixels = vec![0u8; width * height * 3];
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) * 3;
let checker = ((x / 8) + (y / 8)) % 2 == 0;
let base = if checker { 200 } else { 55 };
pixels[idx] = (base + (x % 56)) as u8;
pixels[idx + 1] = (base + (y % 56)) as u8;
pixels[idx + 2] = (base + ((x + y) % 56)) as u8;
}
}
pixels
}
fn test_all_decoders(
original: &[u8],
jpeg_data: &[u8],
width: usize,
height: usize,
config: &TestEncodingConfig,
) -> HashMap<String, (f64, f64, u64)> {
let mut results: HashMap<String, (f64, f64, u64)> = HashMap::new();
let decoder_fns: Vec<(&str, fn(&[u8]) -> Option<DecoderResult>)> = vec![
("jpegli-rs", decode_jpegli),
("zune-jpeg", decode_zune_jpeg),
];
let mut decoder_results: Vec<DecoderResult> = Vec::new();
for (name, decoder_fn) in &decoder_fns {
match decoder_fn(jpeg_data) {
Some(result) => {
assert_eq!(
result.width, width,
"{}: width mismatch for {}",
name, config.name
);
assert_eq!(
result.height, height,
"{}: height mismatch for {}",
name, config.name
);
decoder_results.push(result);
}
None => {
eprintln!(" {} failed to decode {}", name, config.name);
}
}
}
if decoder_results.is_empty() {
panic!("No decoder could decode {}", config.name);
}
let reference = &decoder_results[0];
for result in &decoder_results {
let butteraugli = compute_butteraugli_score(original, &result.pixels, width, height);
let inter_dssim = if result.decoder_name == reference.decoder_name {
0.0
} else {
compute_dssim(&reference.pixels, &result.pixels, width, height)
};
if result.decoder_name != reference.decoder_name {
let max_diff = max_pixel_diff(&reference.pixels, &result.pixels);
if max_diff > MAX_PIXEL_DIFF {
eprintln!(
" WARNING: {} vs {} max pixel diff: {} (threshold: {})",
result.decoder_name, reference.decoder_name, max_diff, MAX_PIXEL_DIFF
);
}
}
results.insert(
result.decoder_name.clone(),
(butteraugli, inter_dssim, result.decode_time_us),
);
}
results
}
#[test]
fn test_multi_decoder_compatibility() {
let width = 256;
let height = 256;
let original = generate_test_image(width, height);
let configs = vec![
TestEncodingConfig {
quality: 95,
subsampling: ChromaSubsampling::None,
progressive: false,
name: "Q95_444_baseline".to_string(),
},
TestEncodingConfig {
quality: 90,
subsampling: ChromaSubsampling::None,
progressive: false,
name: "Q90_444_baseline".to_string(),
},
TestEncodingConfig {
quality: 80,
subsampling: ChromaSubsampling::None,
progressive: false,
name: "Q80_444_baseline".to_string(),
},
TestEncodingConfig {
quality: 70,
subsampling: ChromaSubsampling::Quarter,
progressive: false,
name: "Q70_420_baseline".to_string(),
},
TestEncodingConfig {
quality: 50,
subsampling: ChromaSubsampling::Quarter,
progressive: false,
name: "Q50_420_baseline".to_string(),
},
TestEncodingConfig {
quality: 90,
subsampling: ChromaSubsampling::None,
progressive: true,
name: "Q90_444_progressive".to_string(),
},
TestEncodingConfig {
quality: 80,
subsampling: ChromaSubsampling::HalfHorizontal,
progressive: false,
name: "Q80_422_baseline".to_string(),
},
TestEncodingConfig {
quality: 80,
subsampling: ChromaSubsampling::HalfVertical,
progressive: false,
name: "Q80_440_baseline".to_string(),
},
];
println!("\n=== Multi-Decoder Compatibility Test ===\n");
println!("{:<25} {:>12} {:>12}", "Config", "jpegli-rs", "zune-jpeg");
println!("{}", "-".repeat(55));
let mut all_passed = true;
for config in &configs {
let encoder_config = EncoderConfig::ycbcr(config.quality as f32, config.subsampling)
.progressive(config.progressive);
let jpeg_data = encode_rgb(width as u32, height as u32, &original, &encoder_config)
.expect("jpegli encode failed");
let results = test_all_decoders(&original, &jpeg_data, width, height, config);
let max_allowed = max_butteraugli_for_quality(config.quality);
print!("{:<25}", config.name);
for decoder_name in &["jpegli-rs", "zune-jpeg"] {
if let Some((butteraugli, _, _)) = results.get(*decoder_name) {
let status = if *butteraugli <= max_allowed {
"OK"
} else {
"FAIL"
};
print!(" {:>10.3} {}", butteraugli, status);
if *butteraugli > max_allowed {
all_passed = false;
}
} else {
print!(" {:>12}", "N/A");
}
}
println!();
for (decoder_name, (_, inter_dssim, _)) in &results {
if *inter_dssim > MAX_INTER_DECODER_DSSIM {
eprintln!(
" WARNING: {} inter-decoder DSSIM {:.6} > threshold {:.6} for {}",
decoder_name, inter_dssim, MAX_INTER_DECODER_DSSIM, config.name
);
}
}
}
println!("\n{}", "-".repeat(85));
println!(
"Butteraugli thresholds by quality: Q95={}, Q90={}, Q80={}, Q70={}, Q50={}",
max_butteraugli_for_quality(95),
max_butteraugli_for_quality(90),
max_butteraugli_for_quality(80),
max_butteraugli_for_quality(70),
max_butteraugli_for_quality(50),
);
assert!(
all_passed,
"Some decoder/quality combinations failed butteraugli threshold"
);
}
#[test]
fn test_multi_decoder_complex_image() {
let width = 512;
let height = 384;
let original = generate_complex_test_image(width, height);
println!("\n=== Complex Image Decoder Test ===\n");
let configs = vec![
TestEncodingConfig {
quality: 90,
subsampling: ChromaSubsampling::None,
progressive: false,
name: "complex_Q90_444".to_string(),
},
TestEncodingConfig {
quality: 75,
subsampling: ChromaSubsampling::Quarter,
progressive: false,
name: "complex_Q75_420".to_string(),
},
];
for config in &configs {
let encoder_config = EncoderConfig::ycbcr(config.quality as f32, config.subsampling)
.progressive(config.progressive);
let jpeg_data = encode_rgb(width as u32, height as u32, &original, &encoder_config)
.expect("jpegli encode failed");
println!("{}: {} bytes", config.name, jpeg_data.len());
let results = test_all_decoders(&original, &jpeg_data, width, height, config);
let max_allowed = max_butteraugli_for_quality(config.quality);
for (decoder_name, (butteraugli, inter_dssim, time_us)) in &results {
let status = if *butteraugli <= max_allowed {
"OK"
} else {
"FAIL"
};
println!(
" {:<15}: butteraugli={:.3} {} inter_dssim={:.6} time={}us",
decoder_name, butteraugli, status, inter_dssim, time_us
);
assert!(
*butteraugli <= max_allowed,
"{} butteraugli {:.3} > threshold {:.3} for {}",
decoder_name,
butteraugli,
max_allowed,
config.name
);
}
}
}
#[test]
#[ignore] fn benchmark_decoders() {
let width = 1024;
let height = 768;
let original = generate_test_image(width, height);
let encoder_config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter);
let jpeg_data =
encode_rgb(width as u32, height as u32, &original, &encoder_config).expect("encode failed");
println!(
"\n=== Decoder Benchmark ({}x{}, {} bytes) ===\n",
width,
height,
jpeg_data.len()
);
let iterations = 50;
let decoder_fns: Vec<(&str, fn(&[u8]) -> Option<DecoderResult>)> = vec![
("jpegli-rs", decode_jpegli),
("zune-jpeg", decode_zune_jpeg),
];
for (name, decoder_fn) in &decoder_fns {
let start = std::time::Instant::now();
for _ in 0..iterations {
let _ = decoder_fn(&jpeg_data);
}
let elapsed = start.elapsed();
let avg_us = elapsed.as_micros() as f64 / iterations as f64;
let pixels = width * height;
let mp_per_sec = (pixels as f64 / avg_us) * 1_000_000.0 / 1_000_000.0;
println!(
"{:<15}: {:>8.1} us/decode {:>6.1} MP/s",
name, avg_us, mp_per_sec
);
}
}
#[test]
fn test_grayscale_compatibility() {
let width = 128;
let height = 128;
let mut original = vec![0u8; width * height * 3];
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) * 3;
let gray = ((x * 255 / width) + (y * 255 / height)) as u8 / 2;
original[idx] = gray;
original[idx + 1] = gray;
original[idx + 2] = gray;
}
}
let encoder_config = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter);
let jpeg_data =
encode_rgb(width as u32, height as u32, &original, &encoder_config).expect("encode failed");
println!("\n=== Grayscale Compatibility Test ===\n");
let decoder_fns: Vec<(&str, fn(&[u8]) -> Option<DecoderResult>)> = vec![
("jpegli-rs", decode_jpegli),
("zune-jpeg", decode_zune_jpeg),
];
for (name, decoder_fn) in &decoder_fns {
match decoder_fn(&jpeg_data) {
Some(result) => {
assert_eq!(result.width, width);
assert_eq!(result.height, height);
let butteraugli =
compute_butteraugli_score(&original, &result.pixels, width, height);
println!("{:<15}: butteraugli={:.3}", name, butteraugli);
assert!(
butteraugli < 2.0,
"{} butteraugli too high for Q90 grayscale",
name
);
}
None => {
panic!("{} failed to decode grayscale JPEG", name);
}
}
}
}