jpegli-rs 0.12.0

Pure Rust JPEG encoder/decoder - port of Google's jpegli with perceptual optimizations
Documentation
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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Comprehensive comparison of Rust vs C++ jpegli across corpora.
//!
//! Measures: timing, file size, DSSIM, butteraugli at distance levels.
//!
//! Uses FFI to call C++ jpegli directly (no subprocess overhead).
//!
//! IMPORTANT: Uses `jpegli_set_distance()` for C++ (not `jpeg_set_quality()`)
//! to ensure matching quant table configuration (3 tables for both).

use jpegli::encoder::{ChromaSubsampling, EncoderConfig, PixelLayout, Quality};
use jpegli_bench_utils::{
    ChromaSubsampling as BenchChromaSubsampling, ColorMode, EncoderConfig as BenchEncoderConfig,
    EncoderImpl, ImageData, ScanMode,
};
use std::fs;
use std::path::Path;
use std::time::Instant;

/// Convert quality (0-100) to butteraugli distance.
/// Same formula as C++ jpegli_quality_to_distance.
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
    }
}

fn encode_rust_progressive(
    width: u32,
    height: u32,
    data: &[u8],
    distance: f32,
) -> jpegli::encoder::Result<Vec<u8>> {
    let config = EncoderConfig::ycbcr(
        Quality::ApproxButteraugli(distance),
        ChromaSubsampling::Quarter,
    )
    .progressive(true)
    .optimize_huffman(true);
    let mut enc = config.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
    enc.push_packed(data, enough::Unstoppable)?;
    enc.finish()
}

fn encode_cpp_ffi_progressive(width: u32, height: u32, data: &[u8], distance: f32) -> Vec<u8> {
    let img = ImageData {
        name: "test".to_string(),
        pixels: data.to_vec(),
        width: width as usize,
        height: height as usize,
    };
    BenchEncoderConfig::new(EncoderImpl::CJpegli)
        .color(ColorMode::YCbCr)
        .scan(ScanMode::Progressive)
        .subsampling(BenchChromaSubsampling::S420)
        .distance(distance) // Use distance for 3-table parity
        .encode(&img)
        .expect("C++ jpegli FFI encode failed")
}

fn load_png(path: &Path) -> Option<(Vec<u8>, u32, u32)> {
    let file = fs::File::open(path).ok()?;
    let decoder = png::Decoder::new(file);
    let mut reader = decoder.read_info().ok()?;
    let mut buf = vec![0; reader.output_buffer_size()];
    let info = reader.next_frame(&mut buf).ok()?;

    let width = info.width;
    let height = info.height;

    // Convert to RGB if needed
    let rgb = match info.color_type {
        png::ColorType::Rgb => buf[..info.buffer_size()].to_vec(),
        png::ColorType::Rgba => buf[..info.buffer_size()]
            .chunks(4)
            .flat_map(|c| [c[0], c[1], c[2]])
            .collect(),
        png::ColorType::Grayscale => buf[..info.buffer_size()]
            .iter()
            .flat_map(|&g| [g, g, g])
            .collect(),
        png::ColorType::GrayscaleAlpha => buf[..info.buffer_size()]
            .chunks(2)
            .flat_map(|c| [c[0], c[0], c[0]])
            .collect(),
        _ => return None,
    };

    Some((rgb, width, height))
}

fn compute_dssim(orig: &[u8], decoded: &[u8], width: usize, height: usize) -> f64 {
    let attr = dssim::Dssim::new();

    let orig_rgba: Vec<rgb::RGBA8> = orig
        .chunks(3)
        .map(|c| rgb::RGBA8::new(c[0], c[1], c[2], 255))
        .collect();
    let dec_rgba: Vec<rgb::RGBA8> = decoded
        .chunks(3)
        .map(|c| rgb::RGBA8::new(c[0], c[1], c[2], 255))
        .collect();

    let orig_img = attr.create_image_rgba(&orig_rgba, width, height).unwrap();
    let dec_img = attr.create_image_rgba(&dec_rgba, width, height).unwrap();

    let (dssim, _) = attr.compare(&orig_img, dec_img);
    dssim.into()
}

fn decode_jpeg(data: &[u8]) -> Vec<u8> {
    decode_zune(data).unwrap()
}

#[allow(dead_code)] // Fields used in Debug output and extended reporting
struct ComparisonResult {
    quality: u8,
    distance: f32,
    rust_size: usize,
    cpp_size: usize,
    rust_time_ms: f64,
    cpp_time_ms: f64,
    rust_dssim: f64,
    cpp_dssim: f64,
    rust_butteraugli: f64,
    cpp_butteraugli: f64,
}

fn compare_image(rgb: &[u8], width: u32, height: u32, quality: u8) -> Option<ComparisonResult> {
    // Convert quality to distance for both encoders
    let distance = quality_to_distance(quality as f32);

    // Rust encoding with timing
    let rust_start = Instant::now();
    let rust_jpeg = encode_rust_progressive(width, height, rgb, distance).ok()?;
    let rust_time_ms = rust_start.elapsed().as_secs_f64() * 1000.0;
    let rust_size = rust_jpeg.len();

    // Decode Rust JPEG for quality metrics
    let rust_decoded = decode_jpeg(&rust_jpeg);
    let rust_dssim = compute_dssim(rgb, &rust_decoded, width as usize, height as usize);

    // Rust butteraugli
    let bfly_params = butteraugli::ButteraugliParams::default();
    let rust_butteraugli = butteraugli::compute_butteraugli(
        rgb,
        &rust_decoded,
        width as usize,
        height as usize,
        &bfly_params,
    )
    .expect("butteraugli")
    .score;

    // C++ encoding via FFI with timing (using same distance)
    let cpp_start = Instant::now();
    let cpp_jpeg = encode_cpp_ffi_progressive(width, height, rgb, distance);
    let cpp_time_ms = cpp_start.elapsed().as_secs_f64() * 1000.0;
    let cpp_size = cpp_jpeg.len();

    // Decode C++ JPEG for quality metrics
    let cpp_decoded = decode_jpeg(&cpp_jpeg);
    let cpp_dssim = compute_dssim(rgb, &cpp_decoded, width as usize, height as usize);

    // C++ butteraugli
    let cpp_butteraugli = butteraugli::compute_butteraugli(
        rgb,
        &cpp_decoded,
        width as usize,
        height as usize,
        &bfly_params,
    )
    .expect("butteraugli")
    .score;

    Some(ComparisonResult {
        quality,
        distance,
        rust_size,
        cpp_size,
        rust_time_ms,
        cpp_time_ms,
        rust_dssim,
        cpp_dssim,
        rust_butteraugli,
        cpp_butteraugli,
    })
}

fn find_corpus_images(max_images: usize) -> Vec<std::path::PathBuf> {
    let mut images = Vec::new();

    // PRIORITY: Add frymire.png first - it has odd dimensions (1118x1105)
    // which exercises edge cases in AQ code (1118%8=6, 1105%8=1)
    let frymire_paths = [
        std::path::PathBuf::from("jpegli-rs/tests/images/frymire.png"),
        std::path::PathBuf::from("tests/images/frymire.png"),
        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/images/frymire.png"),
    ];
    for frymire in &frymire_paths {
        if frymire.exists() {
            println!("Adding frymire.png (1118x1105 - odd dimensions for edge case testing)");
            images.push(frymire.clone());
            break;
        }
    }

    // Try CID22-512
    if let Ok(entries) = fs::read_dir("/mnt/v/work/corpus/CID22-512") {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().is_some_and(|e| e == "png") {
                images.push(path);
                if images.len() >= max_images {
                    break;
                }
            }
        }
    }

    // Try testdata
    if images.len() < max_images {
        let testdata_flower = jpegli::test_utils::get_testdata_dir().join("jxl/flower");
        if let Ok(entries) = fs::read_dir(&testdata_flower) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().is_some_and(|e| e == "png") {
                    images.push(path);
                    if images.len() >= max_images {
                        break;
                    }
                }
            }
        }
    }

    images
}

#[test]
#[ignore] // Requires C++ jpegli FFI build and corpus images
fn test_comprehensive_cpp_comparison() {
    let max_images = std::env::var("MAX_IMAGES")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(10);

    let images = find_corpus_images(max_images);
    if images.is_empty() {
        println!("Skipping: no corpus images found");
        return;
    }

    // Quality levels: 2, 4, 6, ..., 100
    let qualities: Vec<u8> = (1..=50).map(|i| i * 2).collect();

    println!("\n{}", "=".repeat(120));
    println!(" COMPREHENSIVE RUST vs C++ JPEGLI COMPARISON (FFI) ");
    println!("{}\n", "=".repeat(120));
    println!("Images: {}", images.len());
    println!("Quality levels: {:?}\n", qualities);

    // Aggregate results per quality level
    let mut aggregated: std::collections::HashMap<u8, Vec<ComparisonResult>> =
        std::collections::HashMap::new();

    for (img_idx, img_path) in images.iter().enumerate() {
        let img_name = img_path.file_name().unwrap().to_str().unwrap();
        println!(
            "[{}/{}] Processing: {}",
            img_idx + 1,
            images.len(),
            img_name
        );

        let (rgb, width, height) = match load_png(img_path) {
            Some(data) => data,
            None => {
                println!("  Skipping: failed to load");
                continue;
            }
        };

        for &q in &qualities {
            if let Some(result) = compare_image(&rgb, width, height, q) {
                aggregated.entry(q).or_default().push(result);
            }
        }
    }

    // Print summary table
    println!("\n{}", "=".repeat(140));
    println!(" SUMMARY (averaged across {} images) ", images.len());
    println!("{}\n", "=".repeat(140));

    println!(
        "{:>4} | {:>10} {:>10} {:>7} | {:>8} {:>8} {:>7} | {:>8} {:>8} {:>7} | {:>8} {:>8} {:>7}",
        "Q",
        "Rust Size",
        "C++ Size",
        "Δ%",
        "Rust ms",
        "C++ ms",
        "Δ%",
        "Rust DSSIM",
        "C++ DSSIM",
        "Δ%",
        "Rust Bfly",
        "C++ Bfly",
        "Δ%"
    );
    println!("{:-<140}", "");

    let mut all_size_diffs = Vec::new();
    let mut all_dssim_diffs = Vec::new();
    let mut all_bfly_diffs = Vec::new();
    let mut all_time_diffs = Vec::new();

    for q in &qualities {
        if let Some(results) = aggregated.get(q) {
            let n = results.len() as f64;

            let avg_rust_size: f64 = results.iter().map(|r| r.rust_size as f64).sum::<f64>() / n;
            let avg_cpp_size: f64 = results.iter().map(|r| r.cpp_size as f64).sum::<f64>() / n;
            let size_diff = (avg_rust_size - avg_cpp_size) / avg_cpp_size * 100.0;

            let avg_rust_time: f64 = results.iter().map(|r| r.rust_time_ms).sum::<f64>() / n;
            let avg_cpp_time: f64 = results.iter().map(|r| r.cpp_time_ms).sum::<f64>() / n;
            let time_diff = (avg_rust_time - avg_cpp_time) / avg_cpp_time * 100.0;

            let avg_rust_dssim: f64 = results.iter().map(|r| r.rust_dssim).sum::<f64>() / n;
            let avg_cpp_dssim: f64 = results.iter().map(|r| r.cpp_dssim).sum::<f64>() / n;
            let dssim_diff = if avg_cpp_dssim > 0.0 {
                (avg_rust_dssim - avg_cpp_dssim) / avg_cpp_dssim * 100.0
            } else {
                0.0
            };

            let avg_rust_bfly: f64 = results.iter().map(|r| r.rust_butteraugli).sum::<f64>() / n;
            let avg_cpp_bfly: f64 = results.iter().map(|r| r.cpp_butteraugli).sum::<f64>() / n;
            let bfly_diff = if avg_cpp_bfly > 0.0 {
                (avg_rust_bfly - avg_cpp_bfly) / avg_cpp_bfly * 100.0
            } else {
                0.0
            };

            all_size_diffs.push(size_diff);
            all_dssim_diffs.push(dssim_diff);
            all_bfly_diffs.push(bfly_diff);
            all_time_diffs.push(time_diff);

            println!(
                "{:>4} | {:>10.0} {:>10.0} {:>+6.1}% | {:>8.2} {:>8.2} {:>+6.1}% | {:>8.6} {:>8.6} {:>+6.1}% | {:>8.4} {:>8.4} {:>+6.1}%",
                q, avg_rust_size, avg_cpp_size, size_diff,
                avg_rust_time, avg_cpp_time, time_diff,
                avg_rust_dssim, avg_cpp_dssim, dssim_diff,
                avg_rust_bfly, avg_cpp_bfly, bfly_diff
            );
        }
    }

    println!("{:-<140}", "");

    // Overall summary
    let avg_size_diff: f64 = all_size_diffs.iter().sum::<f64>() / all_size_diffs.len() as f64;
    let avg_dssim_diff: f64 = all_dssim_diffs.iter().sum::<f64>() / all_dssim_diffs.len() as f64;
    let avg_bfly_diff: f64 = all_bfly_diffs.iter().sum::<f64>() / all_bfly_diffs.len() as f64;
    let avg_time_diff: f64 = all_time_diffs.iter().sum::<f64>() / all_time_diffs.len() as f64;

    println!("\nOVERALL AVERAGES:");
    println!(
        "  Size difference:       {:>+.2}% (positive = Rust larger)",
        avg_size_diff
    );
    println!(
        "  Time difference:       {:>+.2}% (positive = Rust slower)",
        avg_time_diff
    );
    println!(
        "  DSSIM difference:      {:>+.2}% (positive = Rust worse)",
        avg_dssim_diff
    );
    println!(
        "  Butteraugli difference: {:>+.2}% (positive = Rust worse)",
        avg_bfly_diff
    );

    // Quality parity assessment
    println!("\nQUALITY PARITY ASSESSMENT:");
    let dssim_match_count = all_dssim_diffs.iter().filter(|d| d.abs() < 5.0).count();
    let bfly_match_count = all_bfly_diffs.iter().filter(|d| d.abs() < 5.0).count();
    println!(
        "  DSSIM within 5%: {}/{} quality levels",
        dssim_match_count,
        all_dssim_diffs.len()
    );
    println!(
        "  Butteraugli within 5%: {}/{} quality levels",
        bfly_match_count,
        all_bfly_diffs.len()
    );
}

fn decode_zune(data: &[u8]) -> Result<Vec<u8>, zune_jpeg::errors::DecodeErrors> {
    use zune_jpeg::zune_core::bytestream::ZCursor;
    use zune_jpeg::JpegDecoder;
    let cursor = ZCursor::new(data);
    let mut decoder = JpegDecoder::new(cursor);
    decoder.decode()
}