Oregami
Repositories/oxedyne/fe2o3

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
22use oxedyne_fe2o3_core::prelude::*;
23use oxedyne_fe2o3_graphics::{
24 heif::{
25 Heif,
26 Picture,
27 },
28 hevc,
29};
30
31use std::{
32 path::{
33 Path,
34 PathBuf,
35 },
36 process::Command,
37};
38
39const FILES: usize = 3; // photographs a run reads unless it is told otherwise
40
41/// Where the reference pictures are written.
42fn 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
49struct 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.
56fn 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.
70struct 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.
81fn 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]
233fn 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
290fn 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
311fn 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}