Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/tests/jpeg_oracle.rs

13.6 KiB, 13 runs

created by r1870400018:17668, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Decode JPEGs that another implementation wrote, and check we read the pixels it says they hold.
2//!
3//! A codec tested by round trip through itself proves only that it is self-consistent, which a codec
4//! that misreads the format is too. Every fixture in `tests/jpeg/` was therefore compressed by
5//! ImageMagick, and the pixels each is checked against are ImageMagick's own reading of it, decoded
6//! back out to a PPM. Nothing in the comparison originates here.
7//!
8//! The images the fixtures were made from are synthetic -- gradients, deterministic noise, saturated
9//! primaries, a single pixel, and dimensions that are a multiple of neither a block nor an MCU --
10//! and `tests/jpeg/gen.sh` generates them along with everything else in that directory.
11//!
12//! The tolerance is two levels a channel. Two decoders of the same file are not obliged to agree
13//! exactly: the inverse DCT is specified by its result rather than its arithmetic, and different
14//! roundings are legal. In practice this decoder uses the same integer transform, colour transform
15//! and chroma filter libjpeg does, so most fixtures agree to the last bit; the test prints the worst
16//! divergence it saw so that a drift towards the tolerance is visible before it crosses it.
17//!
18//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
19//! Anthropic Claude
20
21use oxedyne_fe2o3_core::prelude::*;
22use oxedyne_fe2o3_graphics::{
23 jpeg,
24 pixmap::Pixmap,
25};
26
27use std::{
28 fs,
29 path::{
30 Path,
31 PathBuf,
32 },
33 process::Command,
34};
35
36const TOL: i32 = 2; // how far a channel may differ from the reference before the test fails
37
38// Every fixture: its name, the size it should decode to, and whether its chrominance is carried at
39// full resolution, which decides how closely a block's mean can be expected to survive subsampling.
40const CASES: &[(&str, usize, usize, bool)] = &[
41 ("gradient_q90_444", 64, 48, true),
42 ("gradient_q75_420", 64, 48, false),
43 ("gradient_q50_422", 64, 48, false),
44 ("gradient_q80_prog", 64, 48, false),
45 ("noise_q95_444", 33, 17, true),
46 ("noise_q60_420", 33, 17, false),
47 ("noise_q85_prog", 33, 17, true),
48 ("noise_q80_rst", 33, 17, false),
49 ("primaries_q92_444", 48, 32, true),
50 ("primaries_q70_420", 48, 32, false),
51 ("primaries_q70_422", 48, 32, false),
52 ("tiny_q90_420", 1, 1, false),
53 ("odd_q88_444", 17, 13, true),
54 ("odd_q64_420", 17, 13, false),
55 ("odd_q88_prog", 17, 13, false),
56 ("gradient_q80_1x2", 64, 48, false),
57 ("narrow_q90_420", 4, 6, false),
58 ("narrow_q90_422", 4, 6, false),
59 ("ramp_q90_grey", 40, 24, true),
60 ("ramp_q90_prog", 40, 24, true),
61];
62
63fn dir() -> PathBuf {
64 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("jpeg")
65}
66
67/// Reads a binary PPM, which is what ImageMagick writes its reference decodings as.
68fn read_ppm(path: &Path) -> Outcome<(usize, usize, Vec<u8>)> {
69 let buf = res!(fs::read(path), IO, File);
70 // The header is three whitespace-separated fields after the magic, and comments may sit between.
71 let mut fields: Vec<usize> = Vec::new();
72 let mut i = 2usize;
73 if buf.len() < 2 || &buf[0..2] != b"P6" {
74 return Err(err!("{} is not a binary PPM.", path.display(); Test, Invalid, Input));
75 }
76 while fields.len() < 3 {
77 while i < buf.len() && (buf[i] as char).is_ascii_whitespace() {
78 i += 1;
79 }
80 if i < buf.len() && buf[i] == b'#' {
81 while i < buf.len() && buf[i] != b'\n' {
82 i += 1;
83 }
84 continue;
85 }
86 let start = i;
87 while i < buf.len() && (buf[i] as char).is_ascii_digit() {
88 i += 1;
89 }
90 if i == start {
91 return Err(err!("{} has a malformed PPM header.", path.display(); Test, Invalid, Input));
92 }
93 let s = String::from_utf8_lossy(&buf[start..i]).to_string();
94 fields.push(res!(s.parse::<usize>(), Test, Invalid));
95 }
96 i += 1; // The single whitespace byte that ends the header.
97 let (w, h, max) = (fields[0], fields[1], fields[2]);
98 if max != 255 {
99 return Err(err!(
100 "{} declares a maximum sample of {}, and this reader wants 255.", path.display(), max;
101 Test, Invalid, Input));
102 }
103 let need = w * h * 3;
104 if buf.len() < i + need {
105 return Err(err!(
106 "{} declares {} by {} pixels, needing {} bytes, but carries {}.",
107 path.display(), w, h, need, buf.len() - i;
108 Test, Invalid, Input));
109 }
110 Ok((w, h, buf[i..i + need].to_vec()))
111}
112
113/// The largest per-channel difference between a decoded pixmap and a reference, and where it was.
114fn worst(pm: &Pixmap, w: usize, h: usize, want: &[u8]) -> Outcome<(i32, usize, usize)> {
115 let mut worst = (0i32, 0usize, 0usize);
116 for y in 0..h {
117 for x in 0..w {
118 let c = match pm.pixel(x, y) {
119 Some(c) => c,
120 None => return Err(err!(
121 "The decoded pixmap has no pixel at ({}, {}).", x, y; Test, Missing)),
122 };
123 let at = (y * w + x) * 3;
124 let d = [
125 ((c.r as i32) - (want[at] as i32)).abs(),
126 ((c.g as i32) - (want[at + 1] as i32)).abs(),
127 ((c.b as i32) - (want[at + 2] as i32)).abs(),
128 ];
129 for v in d {
130 if v > worst.0 {
131 worst = (v, x, y);
132 }
133 }
134 }
135 }
136 Ok(worst)
137}
138
139#[test]
140fn test_imagemagicks_jpegs_decode_to_the_pixels_imagemagick_reads_from_them() -> Outcome<()> {
141 let dir = dir();
142 let mut overall = 0i32;
143 for (name, w, h, _) in CASES {
144 let jpg = dir.join(fmt!("{}.jpg", name));
145 let ppm = dir.join(fmt!("{}.ppm", name));
146 let buf = res!(fs::read(&jpg), IO, File);
147 let pm = res!(jpeg::decode(&buf), Decode, Input);
148 let (rw, rh, want) = res!(read_ppm(&ppm));
149
150 req!(pm.width(), *w, "Width of {}.", name);
151 req!(pm.height(), *h, "Height of {}.", name);
152 req!(rw, *w, "Width of the reference decoding of {}.", name);
153 req!(rh, *h, "Height of the reference decoding of {}.", name);
154
155 let (d, x, y) = res!(worst(&pm, *w, *h, &want));
156 if d > TOL {
157 let c = match pm.pixel(x, y) {
158 Some(c) => c,
159 None => return Err(err!("No pixel at ({}, {}).", x, y; Test, Missing)),
160 };
161 let at = (y * w + x) * 3;
162 return Err(err!(
163 "Decoding {} diverges from ImageMagick's own reading of it by {} at pixel ({}, {}): \
164 it reads ({}, {}, {}) and we read ({}, {}, {}).",
165 name, d, x, y, want[at], want[at + 1], want[at + 2], c.r, c.g, c.b;
166 Test, Mismatch));
167 }
168 overall = overall.max(d);
169 }
170 println!("The worst divergence across {} fixtures was {}.", CASES.len(), overall);
171 Ok(())
172}
173
174#[test]
175fn test_the_size_probe_agrees_with_a_full_decode() -> Outcome<()> {
176 let dir = dir();
177 for (name, w, h, _) in CASES {
178 let buf = res!(fs::read(dir.join(fmt!("{}.jpg", name))), IO, File);
179 let (pw, ph) = res!(jpeg::dimensions(&buf), Decode, Input);
180 req!(pw, *w, "The probe's width for {}.", name);
181 req!(ph, *h, "The probe's height for {}.", name);
182 }
183 Ok(())
184}
185
186#[test]
187fn test_the_eighth_scale_decode_holds_each_blocks_mean() -> Outcome<()> {
188 // A block's DC coefficient is eight times its mean, so a decode that reads that coefficient and
189 // nothing else must reproduce the mean of each block of the full decode. Only the blocks that lie
190 // wholly inside the image are checked: an edge block's coefficients describe the padding the
191 // encoder replicated into it as well, which the full-size image then crops away.
192 //
193 // Only the fixtures whose chrominance was not subsampled can be checked this way. Where it was,
194 // a chrominance block covers sixteen pixels rather than eight, so the reduced image carries its
195 // colour at half the resolution of its luminance -- which is what the file holds, and what every
196 // other reduced-scale decoder produces, but it is not the mean of the block.
197 let dir = dir();
198 for (name, w, h, full_chroma) in CASES {
199 if !*full_chroma {
200 continue;
201 }
202 let buf = res!(fs::read(dir.join(fmt!("{}.jpg", name))), IO, File);
203 let full = res!(jpeg::decode(&buf), Decode, Input);
204 let small = res!(jpeg::decode_eighth(&buf), Decode, Input);
205 req!(small.width(), (w + 7) / 8, "The eighth-scale width of {}.", name);
206 req!(small.height(), (h + 7) / 8, "The eighth-scale height of {}.", name);
207
208 let tol = 4i64;
209 for by in 0..(h / 8) {
210 for bx in 0..(w / 8) {
211 let mut sum = [0i64; 3];
212 for y in 0..8 {
213 for x in 0..8 {
214 let c = match full.pixel(bx * 8 + x, by * 8 + y) {
215 Some(c) => c,
216 None => return Err(err!(
217 "No pixel at ({}, {}) of {}.", bx * 8 + x, by * 8 + y, name;
218 Test, Missing)),
219 };
220 sum[0] += c.r as i64;
221 sum[1] += c.g as i64;
222 sum[2] += c.b as i64;
223 }
224 }
225 let got = match small.pixel(bx, by) {
226 Some(c) => c,
227 None => return Err(err!(
228 "No pixel at ({}, {}) of the eighth-scale {}.", bx, by, name; Test, Missing)),
229 };
230 for (i, v) in [got.r, got.g, got.b].iter().enumerate() {
231 let mean = sum[i] / 64;
232 if (mean - (*v as i64)).abs() > tol {
233 return Err(err!(
234 "Block ({}, {}) of {} has a mean of {} in channel {}, but the \
235 eighth-scale decode gives {}.", bx, by, name, mean, i, v;
236 Test, Mismatch));
237 }
238 }
239 }
240 }
241 }
242 Ok(())
243}
244
245// ┌───────────────────────────────────────────────────────────────────────────┐
246// │ THE ENCODER │
247// └───────────────────────────────────────────────────────────────────────────┘
248
249/// Is ImageMagick on the path? The encoder's oracle is its decoder.
250fn have_convert() -> bool {
251 Command::new("convert")
252 .arg("-version")
253 .output()
254 .map(|o| o.status.success())
255 .unwrap_or(false)
256}
257
258/// A synthetic pixmap: a two-axis gradient with a hard-edged patch of saturated colour in it.
259///
260/// The grey form drops the patch and the colour, so that a greyscale encoding can be measured
261/// against a source it could in principle reproduce.
262fn source(w: usize, h: usize, grey: bool) -> Outcome<Pixmap> {
263 let mut pm = res!(Pixmap::new(w, h));
264 let d = pm.data_mut();
265 for y in 0..h {
266 for x in 0..w {
267 let at = (y * w + x) * 4;
268 let hard = x * 3 > w && x * 3 < w * 2 && y * 3 > h && y * 3 < h * 2;
269 let (r, g, b) = if grey {
270 let v = (((x * 160) / w.max(1)) + ((y * 90) / h.max(1))) as u8;
271 (v, v, v)
272 } else if hard {
273 (230u8, 20u8, 40u8)
274 } else {
275 (((x * 255) / w.max(1)) as u8, ((y * 255) / h.max(1)) as u8, 128u8)
276 };
277 d[at] = r;
278 d[at + 1] = g;
279 d[at + 2] = b;
280 d[at + 3] = 255;
281 }
282 }
283 Ok(pm)
284}
285
286/// The root-mean-square difference between a pixmap and a PPM of the same size.
287fn rmse(pm: &Pixmap, want: &[u8]) -> f64 {
288 let mut sum = 0f64;
289 let d = pm.data();
290 let n = pm.width() * pm.height();
291 for i in 0..n {
292 for c in 0..3 {
293 let e = (d[i * 4 + c] as f64) - (want[i * 3 + c] as f64);
294 sum += e * e;
295 }
296 }
297 (sum / ((n * 3) as f64)).sqrt()
298}
299
300#[test]
301fn test_imagemagick_reads_back_what_this_encoder_writes() -> Outcome<()> {
302 if !have_convert() {
303 println!("ImageMagick is not installed, so the encoder's oracle test is skipped.");
304 return Ok(());
305 }
306 let tmp = std::env::temp_dir().join(fmt!("fe2o3_jpeg_enc_{}", std::process::id()));
307 res!(fs::create_dir_all(&tmp), IO, File);
308
309 let cases: &[(usize, usize, u8, jpeg::Chroma, bool, f64)] = &[
310 (64, 48, 95, jpeg::Chroma::Full, false, 3.0),
311 (64, 48, 85, jpeg::Chroma::Half, false, 9.0),
312 (64, 48, 85, jpeg::Chroma::Quarter, false, 11.0),
313 (64, 48, 30, jpeg::Chroma::Quarter, false, 20.0),
314 (17, 13, 90, jpeg::Chroma::Quarter, false, 20.0),
315 (1, 1, 90, jpeg::Chroma::Quarter, false, 6.0),
316 (40, 24, 90, jpeg::Chroma::Full, true, 3.0),
317 (40, 24, 60, jpeg::Chroma::Full, true, 6.0),
318 ];
319
320 for (i, (w, h, q, chroma, grey, limit)) in cases.iter().enumerate() {
321 let pm = res!(source(*w, *h, *grey));
322 let opts = jpeg::Options { quality: *q, chroma: *chroma, grey: *grey };
323 let buf = res!(jpeg::encode_with(&pm, &opts), Encode);
324
325 let jpg = tmp.join(fmt!("case{}.jpg", i));
326 let ppm = tmp.join(fmt!("case{}.ppm", i));
327 res!(fs::write(&jpg, &buf), IO, File);
328 let out = res!(Command::new("convert")
329 .arg(&jpg)
330 .arg(&ppm)
331 .output(), IO);
332 if !out.status.success() {
333 return Err(err!(
334 "ImageMagick refused a JPEG this encoder wrote at {} by {}, quality {}: {}",
335 w, h, q, String::from_utf8_lossy(&out.stderr);
336 Test, Invalid, Encode));
337 }
338 let (rw, rh, want) = res!(read_ppm(&ppm));
339 req!(rw, *w, "The width ImageMagick reads back at quality {}.", q);
340 req!(rh, *h, "The height ImageMagick reads back at quality {}.", q);
341
342 let e = rmse(&pm, &want);
343 if e > *limit {
344 return Err(err!(
345 "A {} by {} image encoded at quality {} comes back from ImageMagick with an RMSE of \
346 {:.2}, over the limit of {:.2}.", w, h, q, e, limit;
347 Test, Excessive));
348 }
349 println!(
350 "{} by {}, quality {}, {:?}{}: RMSE {:.2}.",
351 w, h, q, chroma, if *grey { ", greyscale" } else { "" }, e,
352 );
353 }
354 res!(fs::remove_dir_all(&tmp), IO, File);
355 Ok(())
356}
357
358#[test]
359fn test_this_codecs_own_round_trip_holds_its_colours() -> Outcome<()> {
360 // Weaker than the oracle above, but it exercises the decoder against a bitstream this crate's
361 // encoder wrote, which the ImageMagick fixtures never are.
362 let pm = res!(source(37, 23, false));
363 let opts = jpeg::Options {
364 quality: 95,
365 chroma: jpeg::Chroma::Full,
366 grey: false,
367 };
368 let buf = res!(jpeg::encode_with(&pm, &opts), Encode);
369 let back = res!(jpeg::decode(&buf), Decode);
370 req!(back.width(), 37usize);
371 req!(back.height(), 23usize);
372 let mut worst = 0i32;
373 for y in 0..23 {
374 for x in 0..37 {
375 let (a, b) = match (pm.pixel(x, y), back.pixel(x, y)) {
376 (Some(a), Some(b)) => (a, b),
377 _ => return Err(err!("No pixel at ({}, {}).", x, y; Test, Missing)),
378 };
379 for (p, q) in [(a.r, b.r), (a.g, b.g), (a.b, b.b)] {
380 worst = worst.max(((p as i32) - (q as i32)).abs());
381 }
382 }
383 }
384 if worst > 24 {
385 return Err(err!(
386 "A quality 95 round trip through this codec moves a channel by {}.", worst;
387 Test, Excessive));
388 }
389 Ok(())
390}