oxedyne/fe2o3/fe2o3_graphics/tests/hevc_tiles.rs
10.4 KiB, 25 runs
created by r1870400018:20530, 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 | //! The HEVC decoder against an independent one, tile by tile. |
| 2 | //! |
| 3 | //! Nothing in the decoder can be trusted on its own evidence. Every table in it has been checked |
| 4 | //! against the published document, and every piece of arithmetic against a property that document |
| 5 | //! implies, but the only thing that says a *picture* is right is another decoder's picture. |
| 6 | //! |
| 7 | //! So this decodes real HEIC photographs and compares them, sample by sample, with what `ffmpeg` |
| 8 | //! makes of the same file -- the whole way, both loop filters included. It is driven by an |
| 9 | //! environment variable naming a directory of them, because no checkout carries a photograph: |
| 10 | //! |
| 11 | //! ```bash |
| 12 | //! HEVC_CORPUS=/srv/nfs4/Gallery/2021 \ |
| 13 | //! cargo test -p oxedyne_fe2o3_graphics --test hevc_tiles -- --nocapture |
| 14 | //! ``` |
| 15 | //! |
| 16 | //! `HEVC_FILES` caps how many are read, for a quick run. Nothing here writes anywhere but a |
| 17 | //! scratch directory under the cache. |
| 18 | //! |
| 19 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 20 | //! Anthropic Claude |
| 21 | |
| 22 | use oxedyne_fe2o3_core::prelude::*; |
| 23 | use oxedyne_fe2o3_graphics::{ |
| 24 | heif::{ |
| 25 | Heif, |
| 26 | Picture, |
| 27 | }, |
| 28 | hevc, |
| 29 | }; |
| 30 | |
| 31 | use std::{ |
| 32 | path::{ |
| 33 | Path, |
| 34 | PathBuf, |
| 35 | }, |
| 36 | process::Command, |
| 37 | }; |
| 38 | |
| 39 | const FILES: usize = 3; // photographs a run reads unless it is told otherwise |
| 40 | |
| 41 | /// Where the reference pictures are written. |
| 42 | fn scratch() -> PathBuf { |
| 43 | match std::env::var("HOME") { |
| 44 | Ok(home) => PathBuf::from(home).join(".cache/hevc-oracle"), |
| 45 | Err(_) => PathBuf::from(".hevc-oracle"), |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | struct Rgb { |
| 50 | w: usize, |
| 51 | h: usize, |
| 52 | px: Vec<u8>, |
| 53 | } |
| 54 | |
| 55 | /// Reads a PNG with this library's own decoder, which is checked against ImageMagick elsewhere. |
| 56 | fn read_png(path: &Path) -> Outcome<Rgb> { |
| 57 | let bytes = res!(std::fs::read(path), IO, File); |
| 58 | let pm = res!(oxedyne_fe2o3_graphics::png::decode(&bytes)); |
| 59 | let (w, h) = (pm.width(), pm.height()); |
| 60 | let mut px = Vec::with_capacity(w * h * 3); |
| 61 | for p in pm.data().chunks_exact(4) { |
| 62 | px.push(p[0]); |
| 63 | px.push(p[1]); |
| 64 | px.push(p[2]); |
| 65 | } |
| 66 | Ok(Rgb { w, h, px }) |
| 67 | } |
| 68 | |
| 69 | /// What one photograph's comparison came to. |
| 70 | struct Verdict { |
| 71 | samples: usize, // how many were compared |
| 72 | differing: usize, // how many differed at all |
| 73 | worst: i32, // the largest difference in any one sample |
| 74 | mean: f64, // the mean absolute difference |
| 75 | } |
| 76 | |
| 77 | /// Decodes one tile with each decoder and compares the brightness plane. |
| 78 | /// |
| 79 | /// Only luma, and only the first tile: colour goes through a conversion neither decoder is being |
| 80 | /// asked about here, and one tile is where a fault shows first. |
| 81 | fn compare(file: &Path, out: &Path) -> Outcome<Option<Verdict>> { |
| 82 | println!(" reading {}", file.display()); |
| 83 | let bytes = res!(std::fs::read(file), IO, File); |
| 84 | let heif = match Heif::read(&bytes) { |
| 85 | Ok(h) => h, |
| 86 | // Not a HEIC at all: one in ten of the corpus is a JPEG under that name. Said out loud, |
| 87 | // because a run that skipped everything in silence would report as a run that passed. |
| 88 | Err(e) => { |
| 89 | println!(" skipped {}: {}", file.display(), e.plain()); |
| 90 | return Ok(None); |
| 91 | }, |
| 92 | }; |
| 93 | let (item, tile_w, tile_h) = match res!(heif.picture()) { |
| 94 | Picture::One { item, size } => (item, size.0 as usize, size.1 as usize), |
| 95 | Picture::Tiled { grid, tiles } => { |
| 96 | let first = match tiles.first() { |
| 97 | Some(t) => *t, |
| 98 | None => return Ok(None), |
| 99 | }; |
| 100 | // The grid says how many tiles and how big the assembled picture is; a tile's own |
| 101 | // size comes out of the decoder, so the overlap below settles it. |
| 102 | (first, grid.width as usize / grid.cols.max(1) as usize, |
| 103 | grid.height as usize / grid.rows.max(1) as usize) |
| 104 | }, |
| 105 | Picture::Foreign { kind, .. } => { |
| 106 | println!(" skipped {}: it holds {:?}", file.display(), String::from_utf8_lossy(&kind)); |
| 107 | return Ok(None); |
| 108 | }, |
| 109 | }; |
| 110 | let config = res!(heif.config(item)); |
| 111 | let data = res!(heif.data(item)); |
| 112 | let mine = res!(hevc::picture(config, &data)); |
| 113 | |
| 114 | // The reference: ffmpeg's own decode of the same file, which assembles the grid. |
| 115 | // |
| 116 | // Asked for as raw 4:2:0 rather than as a picture, so that the comparison is brightness |
| 117 | // against brightness with no colour conversion in the way. A conversion in the middle would |
| 118 | // make an exact answer impossible and hide a decoder that was nearly right. |
| 119 | let raw = out.join("reference.yuv"); |
| 120 | let _ = std::fs::remove_file(&raw); |
| 121 | // With the loop filters on, because this decoder runs them. `HEVC_NO_FILTERS` turns them off |
| 122 | // at both ends, which is how a fault in the codec proper is separated from one in the two |
| 123 | // filters over it -- their whole contribution to a tile of this corpus is 8,722 samples out of |
| 124 | // 262,144, none by more than two, so with a filter wrong the codec's own faults hide under a |
| 125 | // haze of ones and twos. |
| 126 | let mut cmd = Command::new("ffmpeg"); |
| 127 | cmd.args(["-v", "error"]); |
| 128 | if std::env::var("HEVC_NO_FILTERS").is_ok() { |
| 129 | cmd.args(["-skip_loop_filter", "all"]); |
| 130 | } |
| 131 | let run = res!(cmd |
| 132 | .arg("-i") |
| 133 | .arg(file) |
| 134 | .args(["-frames:v", "1", "-pix_fmt", "yuv420p", "-f", "rawvideo", "-y"]) |
| 135 | .arg(&raw) |
| 136 | .output(), IO, File); |
| 137 | if !run.status.success() || !raw.exists() { |
| 138 | println!(" skipped {}: ffmpeg would not read it", file.display()); |
| 139 | return Ok(None); |
| 140 | } |
| 141 | // ffmpeg decodes the FIRST TILE of a grid, not the assembled photograph -- 393,216 bytes for |
| 142 | // a 512 by 512 tile in 4:2:0, whatever the photograph's own size. That is exactly the oracle |
| 143 | // wanted here, and the geometry to compare against is the tile's own coded size. |
| 144 | let (tw, th) = (mine.y.w, mine.y.h); |
| 145 | let bytes = res!(std::fs::read(&raw), IO, File); |
| 146 | if bytes.len() != tw * th * 3 / 2 { |
| 147 | println!(" skipped {}: the reference is {} bytes and a {} by {} tile wants {}", |
| 148 | file.display(), bytes.len(), tw, th, tw * th * 3 / 2); |
| 149 | return Ok(None); |
| 150 | } |
| 151 | let theirs = Rgb { w: tw, h: th, px: bytes }; |
| 152 | |
| 153 | // The tile sits at the top left of the assembled picture, and the assembled picture is cropped |
| 154 | // out of the grid, so the overlap is what both decoders agree exists. |
| 155 | // Both decoders made the same tile, so the whole of it is compared. |
| 156 | let _ = (tile_w, tile_h); |
| 157 | let w = theirs.w.min(mine.y.w); |
| 158 | let h = theirs.h.min(mine.y.h); |
| 159 | if w == 0 || h == 0 { |
| 160 | println!(" skipped {}: tile {}x{}, mine {}x{}, theirs {}x{}", |
| 161 | file.display(), tile_w, tile_h, mine.y.w, mine.y.h, theirs.w, theirs.h); |
| 162 | return Ok(None); |
| 163 | } |
| 164 | let (mut differing, mut worst, mut total) = (0usize, 0i32, 0i64); |
| 165 | let mut first_bad: Option<(usize, usize)> = None; |
| 166 | for y in 0..h { |
| 167 | for x in 0..w { |
| 168 | let a = match mine.y.at(x, y) { |
| 169 | Some(v) => v as i32, |
| 170 | None => continue, |
| 171 | }; |
| 172 | let b = theirs.px[y * theirs.w + x] as i32; |
| 173 | let d = (a - b).abs(); |
| 174 | if d > 0 { |
| 175 | differing += 1; |
| 176 | if first_bad.is_none() { |
| 177 | first_bad = Some((x, y)); |
| 178 | } |
| 179 | } |
| 180 | worst = worst.max(d); |
| 181 | total += d as i64; |
| 182 | } |
| 183 | } |
| 184 | if let Some((x, y)) = first_bad { |
| 185 | println!(" first differs at ({}, {}), which is block ({}, {}) of 32", |
| 186 | x, y, x / 32, y / 32); |
| 187 | } |
| 188 | if worst > 0 { |
| 189 | let stem = file.file_stem().unwrap_or_default().to_string_lossy().to_string(); |
| 190 | res!(write_pgm(&out.join(fmt!("{}-mine.pgm", stem)), &mine.y.px, mine.y.w, mine.y.h)); |
| 191 | let theirs_u16: Vec<u16> = theirs.px[..theirs.w * theirs.h] |
| 192 | .iter().map(|v| *v as u16).collect(); |
| 193 | res!(write_pgm(&out.join(fmt!("{}-ffmpeg.pgm", stem)), &theirs_u16, theirs.w, theirs.h)); |
| 194 | } |
| 195 | // And the colour planes, which are where a fault hides: a decoder can put every brightness |
| 196 | // sample in the right place and still misread the chroma residual, and the only sign of it is |
| 197 | // that everything AFTER that block is wrong. |
| 198 | let (cw, ch) = (theirs.w / 2, theirs.h / 2); |
| 199 | let base = theirs.w * theirs.h; |
| 200 | let mut chroma_bad = 0usize; |
| 201 | let mut first_chroma: Option<(usize, usize)> = None; |
| 202 | for (plane, offset) in [(&mine.cb, base), (&mine.cr, base + cw * ch)] { |
| 203 | for y in 0..ch.min(plane.h) { |
| 204 | for x in 0..cw.min(plane.w) { |
| 205 | let a = match plane.at(x, y) { |
| 206 | Some(v) => v as i32, |
| 207 | None => continue, |
| 208 | }; |
| 209 | let b = theirs.px[offset + y * cw + x] as i32; |
| 210 | if a != b { |
| 211 | chroma_bad += 1; |
| 212 | if first_chroma.is_none() { |
| 213 | first_chroma = Some((x, y)); |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | if let Some((x, y)) = first_chroma { |
| 220 | println!(" colour first differs at ({}, {}) of {} by {}, {} samples out", |
| 221 | x, y, cw, ch, chroma_bad); |
| 222 | } |
| 223 | let samples = w * h; |
| 224 | Ok(Some(Verdict { |
| 225 | samples, |
| 226 | differing, |
| 227 | worst, |
| 228 | mean: total as f64 / samples as f64, |
| 229 | })) |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn test_a_decoded_tile_is_the_tile_another_decoder_makes_00() -> Outcome<()> { |
| 234 | let dir = match std::env::var("HEVC_CORPUS") { |
| 235 | Ok(d) => PathBuf::from(d), |
| 236 | Err(_) => { |
| 237 | println!(" skipped: set HEVC_CORPUS to a directory of HEIC photographs"); |
| 238 | return Ok(()); |
| 239 | }, |
| 240 | }; |
| 241 | if Command::new("ffmpeg").arg("-version").output().is_err() { |
| 242 | println!(" skipped: no ffmpeg to compare against"); |
| 243 | return Ok(()); |
| 244 | } |
| 245 | let want: usize = std::env::var("HEVC_FILES").ok() |
| 246 | .and_then(|s| s.parse().ok()) |
| 247 | .unwrap_or(FILES); |
| 248 | let out = scratch(); |
| 249 | res!(std::fs::create_dir_all(&out), IO, File); |
| 250 | |
| 251 | let mut files: Vec<PathBuf> = Vec::new(); |
| 252 | for entry in walk(&dir) { |
| 253 | let name = entry.to_string_lossy().to_lowercase(); |
| 254 | if name.ends_with(".heic") || name.ends_with(".heif") { |
| 255 | files.push(entry); |
| 256 | } |
| 257 | if files.len() >= want { |
| 258 | break; |
| 259 | } |
| 260 | } |
| 261 | if files.is_empty() { |
| 262 | println!(" skipped: no HEIC photographs under {}", dir.display()); |
| 263 | return Ok(()); |
| 264 | } |
| 265 | |
| 266 | let (mut read, mut exact) = (0usize, 0usize); |
| 267 | for file in &files { |
| 268 | match compare(file, &out) { |
| 269 | Ok(Some(v)) => { |
| 270 | read += 1; |
| 271 | let per_cent = 100.0 * v.differing as f64 / v.samples as f64; |
| 272 | println!( |
| 273 | " {}: {} samples, {:.2}% differ, worst {}, mean {:.3}", |
| 274 | file.file_name().unwrap_or_default().to_string_lossy(), |
| 275 | v.samples, per_cent, v.worst, v.mean); |
| 276 | if v.worst == 0 { |
| 277 | exact += 1; |
| 278 | } |
| 279 | }, |
| 280 | Ok(None) => {}, |
| 281 | Err(e) => println!(" {}: {}", file.display(), e.plain()), |
| 282 | } |
| 283 | } |
| 284 | println!(" {} of {} photographs read, {} of them sample for sample", read, files.len(), exact); |
| 285 | let any = read > 0; |
| 286 | req!(any, true, "not one photograph could be read"); |
| 287 | Ok(()) |
| 288 | } |
| 289 | |
| 290 | fn walk(dir: &Path) -> Vec<PathBuf> { |
| 291 | let mut out = Vec::new(); |
| 292 | let mut stack = vec![dir.to_path_buf()]; |
| 293 | while let Some(at) = stack.pop() { |
| 294 | let entries = match std::fs::read_dir(&at) { |
| 295 | Ok(e) => e, |
| 296 | Err(_) => continue, |
| 297 | }; |
| 298 | for entry in entries.flatten() { |
| 299 | let path = entry.path(); |
| 300 | if path.is_dir() { |
| 301 | stack.push(path); |
| 302 | } else { |
| 303 | out.push(path); |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | out.sort(); |
| 308 | out |
| 309 | } |
| 310 | |
| 311 | fn write_pgm(path: &Path, px: &[u16], w: usize, h: usize) -> Outcome<()> { |
| 312 | let mut out = fmt!("P5\n{} {}\n255\n", w, h).into_bytes(); |
| 313 | out.extend(px.iter().take(w * h).map(|v| *v as u8)); |
| 314 | res!(std::fs::write(path, out), IO, File); |
| 315 | Ok(()) |
| 316 | } |