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 | |
| 21 | use oxedyne_fe2o3_core::prelude::*; |
| 22 | use oxedyne_fe2o3_graphics::{ |
| 23 | jpeg, |
| 24 | pixmap::Pixmap, |
| 25 | }; |
| 26 | |
| 27 | use std::{ |
| 28 | fs, |
| 29 | path::{ |
| 30 | Path, |
| 31 | PathBuf, |
| 32 | }, |
| 33 | process::Command, |
| 34 | }; |
| 35 | |
| 36 | const 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. |
| 40 | const 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 | |
| 63 | fn 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. |
| 68 | fn 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. |
| 114 | fn 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] |
| 140 | fn 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] |
| 175 | fn 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] |
| 187 | fn 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. |
| 250 | fn 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. |
| 262 | fn 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. |
| 287 | fn 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] |
| 301 | fn 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] |
| 359 | fn 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 | } |