oxedyne/fe2o3/fe2o3_graphics/tests/png_matrix.rs
12.1 KiB, 16 runs
created by r1870400018:18277, 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 a PNG of every legal bit depth, colour type and interlace method, and check the pixels |
| 2 | //! against the implementation that wrote the file. |
| 3 | //! |
| 4 | //! Each fixture in `tests/png/` was written by ImageMagick from a source this crate never saw, and |
| 5 | //! the `.pam` beside it is ImageMagick reading its own PNG back out. Nothing in the comparison |
| 6 | //! originates here, so agreement is agreement with an independent implementation rather than with |
| 7 | //! ourselves. `tests/png/gen.sh` regenerates the lot. |
| 8 | //! |
| 9 | //! Each case also declares the bit depth, colour type and interlace method its name claims, and the |
| 10 | //! test reads the fixture's IHDR to check that the file really carries them. ImageMagick silently |
| 11 | //! ignores a `png:bit-depth` it cannot honour, so without that check a matrix could quietly become |
| 12 | //! twenty copies of the eight-bit case and still pass. |
| 13 | //! |
| 14 | //! # The tolerance |
| 15 | //! |
| 16 | //! Fixtures of eight bits and below are compared exactly: there is one right answer and both |
| 17 | //! implementations must reach it. |
| 18 | //! |
| 19 | //! Sixteen-bit fixtures are compared to within one level a channel, because the two reductions to |
| 20 | //! eight bits differ and neither is wrong. This codec keeps the high byte, which is `v >> 8`. |
| 21 | //! ImageMagick's is `v * 255 / 65535` truncated. The two agree exactly whenever the sample is an |
| 22 | //! eight-bit value written twice, which is what a 16-bit file converted up from an 8-bit one holds |
| 23 | //! throughout, and differ by at most one otherwise: `v/256 - v/257` is under one for every `v` a |
| 24 | //! `u16` can hold. The fixtures deliberately carry samples that are *not* multiples of 257, so this |
| 25 | //! tolerance is exercised rather than merely allowed for. |
| 26 | //! |
| 27 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 28 | //! Anthropic Claude |
| 29 | |
| 30 | use oxedyne_fe2o3_core::prelude::*; |
| 31 | use oxedyne_fe2o3_graphics::png; |
| 32 | |
| 33 | use std::{ |
| 34 | fs, |
| 35 | path::{ |
| 36 | Path, |
| 37 | PathBuf, |
| 38 | }, |
| 39 | }; |
| 40 | |
| 41 | /// A fixture: its name, its size, the bit depth and colour type its IHDR must carry, and whether it |
| 42 | /// must be interlaced. |
| 43 | type Case = (&'static str, usize, usize, u8, u8, bool); |
| 44 | |
| 45 | // Every fixture gen.sh writes. The names say what they hold: g greyscale, p palette, pt palette |
| 46 | // with a tRNS chunk, ga greyscale with alpha, rgb truecolour, rgba truecolour with alpha, followed |
| 47 | // by the bit depth, the size, and i for interlaced or n for not. |
| 48 | const CASES: &[Case] = &[ |
| 49 | // One bit of greyscale, at every size, which is the narrowest sample the format has and the |
| 50 | // one whose row padding is widest. |
| 51 | ("g1_1x1_i", 1, 1, 1, 0, true), |
| 52 | ("g1_1x1_n", 1, 1, 1, 0, false), |
| 53 | ("g1_1x5_i", 1, 5, 1, 0, true), |
| 54 | ("g1_1x5_n", 1, 5, 1, 0, false), |
| 55 | ("g1_3x2_i", 3, 2, 1, 0, true), |
| 56 | ("g1_3x2_n", 3, 2, 1, 0, false), |
| 57 | ("g1_5x1_i", 5, 1, 1, 0, true), |
| 58 | ("g1_5x1_n", 5, 1, 1, 0, false), |
| 59 | ("g1_9x9_i", 9, 9, 1, 0, true), |
| 60 | ("g1_9x9_n", 9, 9, 1, 0, false), |
| 61 | ("g1_17x13_i", 17, 13, 1, 0, true), |
| 62 | ("g1_17x13_n", 17, 13, 1, 0, false), |
| 63 | // The rest of the greyscale depths. |
| 64 | ("g2_17x13_i", 17, 13, 2, 0, true), |
| 65 | ("g2_17x13_n", 17, 13, 2, 0, false), |
| 66 | ("g4_17x13_i", 17, 13, 4, 0, true), |
| 67 | ("g4_17x13_n", 17, 13, 4, 0, false), |
| 68 | ("g8_17x13_i", 17, 13, 8, 0, true), |
| 69 | ("g8_17x13_n", 17, 13, 8, 0, false), |
| 70 | ("g16_17x13_i", 17, 13, 16, 0, true), |
| 71 | ("g16_17x13_n", 17, 13, 16, 0, false), |
| 72 | // Palette, at every depth the specification allows it. |
| 73 | ("p1_17x13_i", 17, 13, 1, 3, true), |
| 74 | ("p1_17x13_n", 17, 13, 1, 3, false), |
| 75 | ("p2_17x13_i", 17, 13, 2, 3, true), |
| 76 | ("p2_17x13_n", 17, 13, 2, 3, false), |
| 77 | ("p4_17x13_i", 17, 13, 4, 3, true), |
| 78 | ("p4_17x13_n", 17, 13, 4, 3, false), |
| 79 | ("p8_17x13_i", 17, 13, 8, 3, true), |
| 80 | ("p8_17x13_n", 17, 13, 8, 3, false), |
| 81 | // The same, carrying a tRNS chunk, so that the alpha comes from outside the image data. |
| 82 | ("pt1_17x13_i", 17, 13, 1, 3, true), |
| 83 | ("pt1_17x13_n", 17, 13, 1, 3, false), |
| 84 | ("pt2_17x13_i", 17, 13, 2, 3, true), |
| 85 | ("pt2_17x13_n", 17, 13, 2, 3, false), |
| 86 | ("pt4_17x13_i", 17, 13, 4, 3, true), |
| 87 | ("pt4_17x13_n", 17, 13, 4, 3, false), |
| 88 | ("pt8_17x13_i", 17, 13, 8, 3, true), |
| 89 | ("pt8_17x13_n", 17, 13, 8, 3, false), |
| 90 | // Truecolour and greyscale with alpha, at both depths each allows. |
| 91 | ("rgb8_17x13_i", 17, 13, 8, 2, true), |
| 92 | ("rgb8_17x13_n", 17, 13, 8, 2, false), |
| 93 | ("rgb16_17x13_i", 17, 13, 16, 2, true), |
| 94 | ("rgb16_17x13_n", 17, 13, 16, 2, false), |
| 95 | ("ga8_17x13_i", 17, 13, 8, 4, true), |
| 96 | ("ga8_17x13_n", 17, 13, 8, 4, false), |
| 97 | ("ga16_17x13_i", 17, 13, 16, 4, true), |
| 98 | ("ga16_17x13_n", 17, 13, 16, 4, false), |
| 99 | // Truecolour with alpha at eight and at sixteen bits, at every size: the widest pixel the |
| 100 | // format has, against the same Adam7 geometry the narrowest was put through. |
| 101 | ("rgba8_1x1_i", 1, 1, 8, 6, true), |
| 102 | ("rgba8_1x1_n", 1, 1, 8, 6, false), |
| 103 | ("rgba8_1x5_i", 1, 5, 8, 6, true), |
| 104 | ("rgba8_1x5_n", 1, 5, 8, 6, false), |
| 105 | ("rgba8_3x2_i", 3, 2, 8, 6, true), |
| 106 | ("rgba8_3x2_n", 3, 2, 8, 6, false), |
| 107 | ("rgba8_5x1_i", 5, 1, 8, 6, true), |
| 108 | ("rgba8_5x1_n", 5, 1, 8, 6, false), |
| 109 | ("rgba8_9x9_i", 9, 9, 8, 6, true), |
| 110 | ("rgba8_9x9_n", 9, 9, 8, 6, false), |
| 111 | ("rgba8_17x13_i", 17, 13, 8, 6, true), |
| 112 | ("rgba8_17x13_n", 17, 13, 8, 6, false), |
| 113 | ("rgba16_1x1_i", 1, 1, 16, 6, true), |
| 114 | ("rgba16_1x1_n", 1, 1, 16, 6, false), |
| 115 | ("rgba16_1x5_i", 1, 5, 16, 6, true), |
| 116 | ("rgba16_1x5_n", 1, 5, 16, 6, false), |
| 117 | ("rgba16_3x2_i", 3, 2, 16, 6, true), |
| 118 | ("rgba16_3x2_n", 3, 2, 16, 6, false), |
| 119 | ("rgba16_5x1_i", 5, 1, 16, 6, true), |
| 120 | ("rgba16_5x1_n", 5, 1, 16, 6, false), |
| 121 | ("rgba16_9x9_i", 9, 9, 16, 6, true), |
| 122 | ("rgba16_9x9_n", 9, 9, 16, 6, false), |
| 123 | ("rgba16_17x13_i", 17, 13, 16, 6, true), |
| 124 | ("rgba16_17x13_n", 17, 13, 16, 6, false), |
| 125 | ]; |
| 126 | |
| 127 | fn dir() -> PathBuf { |
| 128 | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("png") |
| 129 | } |
| 130 | |
| 131 | /// The bit depth, colour type and interlace method a PNG's IHDR declares, read straight out of the |
| 132 | /// bytes rather than through the decoder the test is checking. |
| 133 | fn ihdr(buf: &[u8]) -> Outcome<(usize, usize, u8, u8, bool)> { |
| 134 | if buf.len() < 33 { |
| 135 | return Err(err!("A PNG needs 33 bytes to hold a signature and an IHDR."; Test, Invalid)); |
| 136 | } |
| 137 | let w = u32::from_be_bytes([buf[16], buf[17], buf[18], buf[19]]) as usize; |
| 138 | let h = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]) as usize; |
| 139 | Ok((w, h, buf[24], buf[25], buf[28] != 0)) |
| 140 | } |
| 141 | |
| 142 | /// Reads a binary PAM, which is what ImageMagick writes its reference decodings as, and returns it |
| 143 | /// as RGBA. |
| 144 | /// |
| 145 | /// ImageMagick writes a greyscale PNG back out as `GRAYSCALE_ALPHA` rather than `RGB_ALPHA`, and |
| 146 | /// the two channels are widened here rather than by asking ImageMagick for a colourspace change, |
| 147 | /// which would put a transform between the file and the comparison. |
| 148 | fn read_pam(path: &Path) -> Outcome<(usize, usize, Vec<u8>)> { |
| 149 | let buf = res!(fs::read(path), IO, File); |
| 150 | if buf.len() < 3 || &buf[0..3] != b"P7\n" { |
| 151 | return Err(err!("{} is not a binary PAM.", path.display(); Test, Invalid, Input)); |
| 152 | } |
| 153 | let (mut w, mut h, mut chans, mut max) = (0usize, 0usize, 0usize, 0usize); |
| 154 | let mut tuple = String::new(); |
| 155 | let mut i = 3usize; |
| 156 | loop { |
| 157 | let start = i; |
| 158 | while i < buf.len() && buf[i] != b'\n' { |
| 159 | i += 1; |
| 160 | } |
| 161 | if i >= buf.len() { |
| 162 | return Err(err!("{} has no ENDHDR.", path.display(); Test, Invalid, Input)); |
| 163 | } |
| 164 | let line = String::from_utf8_lossy(&buf[start..i]).to_string(); |
| 165 | i += 1; |
| 166 | let mut it = line.split_whitespace(); |
| 167 | let key = it.next().unwrap_or(""); |
| 168 | let val = it.next().unwrap_or(""); |
| 169 | match key { |
| 170 | "ENDHDR" => break, |
| 171 | "WIDTH" => w = res!(val.parse::<usize>(), Test, Invalid), |
| 172 | "HEIGHT" => h = res!(val.parse::<usize>(), Test, Invalid), |
| 173 | "DEPTH" => chans = res!(val.parse::<usize>(), Test, Invalid), |
| 174 | "MAXVAL" => max = res!(val.parse::<usize>(), Test, Invalid), |
| 175 | "TUPLTYPE" => tuple = val.to_string(), |
| 176 | _ => (), |
| 177 | } |
| 178 | } |
| 179 | if max != 255 { |
| 180 | return Err(err!( |
| 181 | "{} declares a maximum sample of {}, and this reader wants 255.", path.display(), max; |
| 182 | Test, Invalid, Input)); |
| 183 | } |
| 184 | let need = w * h * chans; |
| 185 | if buf.len() < i + need { |
| 186 | return Err(err!( |
| 187 | "{} declares {} by {} pixels of {} channels, needing {} bytes, but carries {}.", |
| 188 | path.display(), w, h, chans, need, buf.len() - i; |
| 189 | Test, Invalid, Input)); |
| 190 | } |
| 191 | let px = &buf[i..i + need]; |
| 192 | let mut out = Vec::with_capacity(w * h * 4); |
| 193 | for p in px.chunks_exact(chans) { |
| 194 | match (chans, tuple.as_str()) { |
| 195 | (4, "RGB_ALPHA") => out.extend_from_slice(p), |
| 196 | (3, "RGB") => out.extend_from_slice(&[p[0], p[1], p[2], 255]), |
| 197 | (2, "GRAYSCALE_ALPHA") => out.extend_from_slice(&[p[0], p[0], p[0], p[1]]), |
| 198 | (1, "GRAYSCALE") => out.extend_from_slice(&[p[0], p[0], p[0], 255]), |
| 199 | _ => return Err(err!( |
| 200 | "{} declares {} channels of tuple type '{}', which this reader does not know.", |
| 201 | path.display(), chans, tuple; |
| 202 | Test, Invalid, Input)), |
| 203 | } |
| 204 | } |
| 205 | Ok((w, h, out)) |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn test_imagemagicks_pngs_decode_to_the_pixels_imagemagick_reads_from_them() -> Outcome<()> { |
| 210 | let dir = dir(); |
| 211 | let mut worst_overall = 0i32; |
| 212 | for (name, w, h, depth, ct, laced) in CASES { |
| 213 | let buf = res!(fs::read(dir.join(fmt!("{}.png", name))), IO, File); |
| 214 | |
| 215 | // The fixture must be what its name says, or the matrix has a hole in it. |
| 216 | let (fw, fh, fd, fct, fl) = res!(ihdr(&buf)); |
| 217 | req!(fw, *w, "The IHDR width of {}.", name); |
| 218 | req!(fh, *h, "The IHDR height of {}.", name); |
| 219 | req!(fd, *depth, "The IHDR bit depth of {}.", name); |
| 220 | req!(fct, *ct, "The IHDR colour type of {}.", name); |
| 221 | req!(fl, *laced, "The IHDR interlace method of {}.", name); |
| 222 | |
| 223 | let pm = res!(png::decode(&buf), Decode, Input); |
| 224 | req!(pm.width(), *w, "Decoded width of {}.", name); |
| 225 | req!(pm.height(), *h, "Decoded height of {}.", name); |
| 226 | |
| 227 | let (rw, rh, want) = res!(read_pam(&dir.join(fmt!("{}.pam", name)))); |
| 228 | req!(rw, *w, "Reference width of {}.", name); |
| 229 | req!(rh, *h, "Reference height of {}.", name); |
| 230 | |
| 231 | // Eight bits and below have one right answer; sixteen leaves a level of latitude in the |
| 232 | // reduction, as this file's header explains. |
| 233 | let tol = if *depth == 16 { 1i32 } else { 0i32 }; |
| 234 | for y in 0..*h { |
| 235 | for x in 0..*w { |
| 236 | let c = match pm.pixel(x, y) { |
| 237 | Some(c) => c, |
| 238 | None => return Err(err!( |
| 239 | "The decoding of {} has no pixel at ({}, {}).", name, x, y; Test, Missing)), |
| 240 | }; |
| 241 | let at = (y * w + x) * 4; |
| 242 | let got = [c.r, c.g, c.b, c.a]; |
| 243 | for k in 0..4 { |
| 244 | let d = (got[k] as i32) - (want[at + k] as i32); |
| 245 | if d.abs() > tol { |
| 246 | return Err(err!( |
| 247 | "Decoding {} diverges from ImageMagick's own reading of it at pixel \ |
| 248 | ({}, {}): it reads ({}, {}, {}, {}) and we read ({}, {}, {}, {}).", |
| 249 | name, x, y, |
| 250 | want[at], want[at + 1], want[at + 2], want[at + 3], |
| 251 | got[0], got[1], got[2], got[3]; |
| 252 | Test, Mismatch)); |
| 253 | } |
| 254 | worst_overall = worst_overall.max(d.abs()); |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | println!( |
| 260 | "The worst divergence across {} fixtures was {}.", CASES.len(), worst_overall); |
| 261 | Ok(()) |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn test_the_fixtures_are_not_all_the_same_picture() -> Outcome<()> { |
| 266 | // A matrix of fixtures that all decoded to a flat grey would pass the comparison above without |
| 267 | // exercising anything. Every 17 by 13 fixture must therefore hold at least two distinct |
| 268 | // colours, the tRNS ones must hold at least one fully transparent pixel, and the 1-bit ones |
| 269 | // must hold both extremes rather than one of them. |
| 270 | let dir = dir(); |
| 271 | for (name, w, h, depth, _, _) in CASES { |
| 272 | if *w != 17 { |
| 273 | continue; |
| 274 | } |
| 275 | let buf = res!(fs::read(dir.join(fmt!("{}.png", name))), IO, File); |
| 276 | let pm = res!(png::decode(&buf), Decode, Input); |
| 277 | let mut seen: Vec<(u8, u8, u8, u8)> = Vec::new(); |
| 278 | let mut clear = 0usize; |
| 279 | for y in 0..*h { |
| 280 | for x in 0..*w { |
| 281 | let c = match pm.pixel(x, y) { |
| 282 | Some(c) => c, |
| 283 | None => return Err(err!( |
| 284 | "The decoding of {} has no pixel at ({}, {}).", name, x, y; Test, Missing)), |
| 285 | }; |
| 286 | let t = (c.r, c.g, c.b, c.a); |
| 287 | if !seen.contains(&t) { |
| 288 | seen.push(t); |
| 289 | } |
| 290 | if c.a == 0 { |
| 291 | clear += 1; |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | if seen.len() < 2 { |
| 296 | return Err(err!( |
| 297 | "The fixture {} decodes to a single colour, so it tests nothing.", name; |
| 298 | Test, Invalid)); |
| 299 | } |
| 300 | if name.starts_with("pt") && clear == 0 { |
| 301 | return Err(err!( |
| 302 | "The fixture {} carries a tRNS chunk but decodes fully opaque.", name; |
| 303 | Test, Invalid)); |
| 304 | } |
| 305 | if *depth == 1 && !name.starts_with("p") { |
| 306 | let lo = seen.iter().any(|c| c.0 == 0); |
| 307 | let hi = seen.iter().any(|c| c.0 == 255); |
| 308 | if !(lo && hi) { |
| 309 | return Err(err!( |
| 310 | "The 1-bit fixture {} does not widen to both 0 and 255.", name; Test, Invalid)); |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | Ok(()) |
| 315 | } |