Oregami
Repositories/oxedyne/fe2o3

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
30use oxedyne_fe2o3_core::prelude::*;
31use oxedyne_fe2o3_graphics::png;
32
33use 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.
43type 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.
48const 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
127fn 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.
133fn 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.
148fn 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]
209fn 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]
265fn 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}