Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_hash/tests/phash.rs

11.4 KiB, 11 runs

created by r1870400018:17788, 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//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
2//! Anthropic Claude
3
4use oxedyne_fe2o3_hash::phash::{
5 LumaGrid,
6 PerceptualHash,
7 hamming,
8 luma_from_rgb,
9 luma_from_rgba,
10};
11
12use oxedyne_fe2o3_core::{
13 prelude::*,
14 test::test_it,
15};
16
17use std::{
18 fs,
19 path::PathBuf,
20};
21
22
23struct Pgm {
24 dat: Vec<u8>,
25 w: usize,
26 h: usize,
27}
28
29/// Reads a binary portable greymap, the `P5` form with a maximum value of 255.
30///
31/// The format is deliberately trivial, which lets an external tool produce the fixtures and
32/// leaves the decode entirely outside the library under test.
33fn read_pgm(path: &PathBuf) -> Outcome<Pgm> {
34 let raw = res!(fs::read(path), IO, File);
35 if raw.len() < 2 || &raw[0..2] != b"P5" {
36 return Err(err!(
37 "{:?}: not a binary portable greymap, the first two bytes are {:02x?}.",
38 path, &raw[0..raw.len().min(2)];
39 Input, Invalid));
40 }
41 // Collect three whitespace separated fields after the magic, skipping comment lines.
42 let mut fields: Vec<usize> = Vec::new();
43 let mut i = 2usize;
44 while fields.len() < 3 {
45 while i < raw.len() && (raw[i] as char).is_whitespace() {
46 i += 1;
47 }
48 if i < raw.len() && raw[i] == b'#' {
49 while i < raw.len() && raw[i] != b'\n' {
50 i += 1;
51 }
52 continue;
53 }
54 let start = i;
55 while i < raw.len() && (raw[i] as char).is_ascii_digit() {
56 i += 1;
57 }
58 if start == i {
59 return Err(err!(
60 "{:?}: the header ended at byte {} before three numeric fields were read.",
61 path, i;
62 Input, Invalid));
63 }
64 let s = res!(std::str::from_utf8(&raw[start..i]), Input, Invalid);
65 fields.push(res!(s.parse::<usize>(), Input, Invalid));
66 }
67 i += 1; // The single whitespace byte that closes the header.
68 let (w, h, max) = (fields[0], fields[1], fields[2]);
69 if max != 255 {
70 return Err(err!(
71 "{:?}: maximum sample value {} is not supported, only 255 is.", path, max;
72 Input, Invalid));
73 }
74 if raw.len() < i + w * h {
75 return Err(err!(
76 "{:?}: {} by {} needs {} sample bytes, only {} follow the header.",
77 path, w, h, w * h, raw.len() - i;
78 Input, Invalid, TooSmall));
79 }
80 Ok(Pgm { dat: raw[i..i + w * h].to_vec(), w, h })
81}
82
83fn fixture_path(name: &str) -> PathBuf {
84 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
85 p.push("test_images");
86 p.push(name);
87 p
88}
89
90fn hash_fixture(name: &str) -> Outcome<(PerceptualHash, PerceptualHash)> {
91 let img = res!(read_pgm(&fixture_path(name)));
92 let grid = res!(LumaGrid::new(&img.dat, img.w, img.h));
93 Ok((
94 res!(PerceptualHash::dhash(&grid)),
95 res!(PerceptualHash::phash(&grid)),
96 ))
97}
98
99// The synthetic subjects in the fixture directory, and the transforms an external tool applied
100// to each of them.
101const SUBJECTS: [&str; 3] = ["plasma", "gradient", "shapes"];
102const VARIANTS: [&str; 4] = ["half", "q40", "bright", "png2jpg"];
103
104pub fn test_phash(filter: &'static str) -> Outcome<()> {
105
106 res!(test_it(filter, &["A hash of itself is zero away 000", "all", "phash"], || {
107 let px: Vec<u8> = (0..(48 * 32)).map(|i| ((i * 7) % 251) as u8).collect();
108 let grid = res!(LumaGrid::new(&px, 48, 32));
109 let d = res!(PerceptualHash::dhash(&grid));
110 let p = res!(PerceptualHash::phash(&grid));
111 req!(res!(d.distance(&d)), 0u32);
112 req!(res!(p.distance(&p)), 0u32);
113 // The two kinds are not comparable.
114 if d.distance(&p).is_ok() {
115 return Err(err!(
116 "A difference hash was compared with a cosine transform hash."; Test, Invalid));
117 }
118 Ok(())
119 }));
120
121 res!(test_it(filter, &["Malformed grids are refused 010", "all", "phash"], || {
122 let px = [0u8; 16];
123 if LumaGrid::new(&px, 0, 4).is_ok() {
124 return Err(err!("A zero width grid was accepted."; Test, Invalid));
125 }
126 if LumaGrid::new(&px, 4, 0).is_ok() {
127 return Err(err!("A zero height grid was accepted."; Test, Invalid));
128 }
129 match LumaGrid::new(&px, 8, 8) {
130 Ok(_) => return Err(err!(
131 "A 16 byte buffer was accepted as an 8 by 8 grid."; Test, Invalid)),
132 Err(e) => test!("Short buffer refused: {}", e),
133 }
134 // A grid smaller than the reduction still hashes rather than failing.
135 let tiny = [3u8, 200, 40, 250];
136 let grid = res!(LumaGrid::new(&tiny, 2, 2));
137 let _ = res!(PerceptualHash::dhash(&grid));
138 let _ = res!(PerceptualHash::phash(&grid));
139 Ok(())
140 }));
141
142 res!(test_it(filter, &["Colour conversion follows Rec. 601 020", "all", "phash"], || {
143 // Pure red, green, blue and white, one pixel each.
144 let rgb = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255];
145 let y = res!(luma_from_rgb(&rgb, 4, 1));
146 req!(y, vec![76u8, 150, 29, 255]);
147 let rgba = [255u8, 0, 0, 17, 0, 255, 0, 34, 0, 0, 255, 51, 255, 255, 255, 68];
148 let ya = res!(luma_from_rgba(&rgba, 4, 1));
149 req!(ya, vec![76u8, 150, 29, 255]);
150 if luma_from_rgb(&rgb, 8, 1).is_ok() {
151 return Err(err!("A short interleaved buffer was accepted."; Test, Invalid));
152 }
153 Ok(())
154 }));
155
156 res!(test_it(filter, &["Variants of one subject stay close 030", "all", "phash"], || {
157 // Every fixture in this test was produced by an external tool from the same master:
158 // a half-size reduction, a quality forty re-encode, a ten per cent brightening, and a
159 // lossless to lossy conversion. A perceptual hash that did not survive these would be
160 // of no use, so the distances are asserted, not merely printed.
161 let mut d_same = Vec::new();
162 let mut p_same = Vec::new();
163 for subj in SUBJECTS {
164 let (d0, p0) = res!(hash_fixture(&fmt!("{}_orig.pgm", subj)));
165 for var in VARIANTS {
166 let (d1, p1) = res!(hash_fixture(&fmt!("{}_{}.pgm", subj, var)));
167 let dd = res!(d0.distance(&d1));
168 let pd = res!(p0.distance(&p1));
169 test!("{:>8} vs {:>8}: dhash {:>2}, phash {:>2}", subj, var, dd, pd);
170 d_same.push(dd);
171 p_same.push(pd);
172 if dd > 12 {
173 return Err(err!(
174 "{} against its {} variant: difference hash distance {} is too large \
175 for the same image.", subj, var, dd;
176 Test, Mismatch));
177 }
178 if pd > 10 {
179 return Err(err!(
180 "{} against its {} variant: cosine transform hash distance {} is too \
181 large for the same image.", subj, var, pd;
182 Test, Mismatch));
183 }
184 }
185 }
186 let dmax = d_same.iter().copied().max().unwrap_or(0);
187 let pmax = p_same.iter().copied().max().unwrap_or(0);
188 let dsum: u32 = d_same.iter().sum();
189 let psum: u32 = p_same.iter().sum();
190 test!(
191 "Same subject over {} pairs: dhash mean {:.2} max {}, phash mean {:.2} max {}.",
192 d_same.len(),
193 dsum as f64 / d_same.len() as f64, dmax,
194 psum as f64 / p_same.len() as f64, pmax,
195 );
196 Ok(())
197 }));
198
199 res!(test_it(filter, &["Different subjects stay far apart 040", "all", "phash"], || {
200 let mut d_diff = Vec::new();
201 let mut p_diff = Vec::new();
202 let mut names = Vec::new();
203 for subj in SUBJECTS {
204 for var in ["orig"].iter().chain(VARIANTS.iter()) {
205 names.push((subj, *var, res!(hash_fixture(&fmt!("{}_{}.pgm", subj, var)))));
206 }
207 }
208 for (i, (s1, v1, (d1, p1))) in names.iter().enumerate() {
209 for (s2, v2, (d2, p2)) in names.iter().skip(i + 1) {
210 if s1 == s2 {
211 continue;
212 }
213 let dd = res!(d1.distance(d2));
214 let pd = res!(p1.distance(p2));
215 d_diff.push(dd);
216 p_diff.push(pd);
217 if dd < 16 {
218 return Err(err!(
219 "{}_{} against {}_{}: difference hash distance {} is too small for \
220 unrelated images.", s1, v1, s2, v2, dd;
221 Test, Mismatch));
222 }
223 if pd < 16 {
224 return Err(err!(
225 "{}_{} against {}_{}: cosine transform hash distance {} is too small \
226 for unrelated images.", s1, v1, s2, v2, pd;
227 Test, Mismatch));
228 }
229 }
230 }
231 let dmin = d_diff.iter().copied().min().unwrap_or(0);
232 let pmin = p_diff.iter().copied().min().unwrap_or(0);
233 let dsum: u32 = d_diff.iter().sum();
234 let psum: u32 = p_diff.iter().sum();
235 test!(
236 "Unrelated subjects over {} pairs: dhash mean {:.2} min {}, phash mean {:.2} min {}.",
237 d_diff.len(),
238 dsum as f64 / d_diff.len() as f64, dmin,
239 psum as f64 / p_diff.len() as f64, pmin,
240 );
241 Ok(())
242 }));
243
244 res!(test_it(filter, &["The two populations do not overlap 050", "all", "phash"], || {
245 // The point of a threshold is that it exists. Collect the worst same-subject distance
246 // and the best unrelated distance, and insist on a gap between them.
247 let mut worst_same = (0u32, 0u32);
248 let mut best_diff = (64u32, 64u32);
249 let mut all = Vec::new();
250 for subj in SUBJECTS {
251 for var in ["orig"].iter().chain(VARIANTS.iter()) {
252 all.push((subj, res!(hash_fixture(&fmt!("{}_{}.pgm", subj, var)))));
253 }
254 }
255 for (i, (s1, (d1, p1))) in all.iter().enumerate() {
256 for (s2, (d2, p2)) in all.iter().skip(i + 1) {
257 let dd = res!(d1.distance(d2));
258 let pd = res!(p1.distance(p2));
259 if s1 == s2 {
260 worst_same = (worst_same.0.max(dd), worst_same.1.max(pd));
261 } else {
262 best_diff = (best_diff.0.min(dd), best_diff.1.min(pd));
263 }
264 }
265 }
266 test!(
267 "Separation: dhash worst same {} against best unrelated {}; \
268 phash worst same {} against best unrelated {}.",
269 worst_same.0, best_diff.0, worst_same.1, best_diff.1,
270 );
271 if worst_same.0 >= best_diff.0 {
272 return Err(err!(
273 "The difference hash populations overlap: worst same-subject distance {} is not \
274 below the best unrelated distance {}.", worst_same.0, best_diff.0;
275 Test, Mismatch));
276 }
277 if worst_same.1 >= best_diff.1 {
278 return Err(err!(
279 "The cosine transform hash populations overlap: worst same-subject distance {} \
280 is not below the best unrelated distance {}.", worst_same.1, best_diff.1;
281 Test, Mismatch));
282 }
283 Ok(())
284 }));
285
286 res!(test_it(filter, &["Hamming counts differing bits 060", "all", "phash"], || {
287 req!(hamming(0, 0), 0u32);
288 req!(hamming(u64::MAX, 0), 64u32);
289 req!(hamming(0b1011, 0b0001), 2u32);
290 req!(fmt!("{}", PerceptualHash::DHash(0x0123456789abcdef)), "d:0123456789abcdef");
291 req!(fmt!("{}", PerceptualHash::PHash(0x0123456789abcdef)), "p:0123456789abcdef");
292 Ok(())
293 }));
294
295 Ok(())
296}