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 | |
| 4 | use oxedyne_fe2o3_hash::phash::{ |
| 5 | LumaGrid, |
| 6 | PerceptualHash, |
| 7 | hamming, |
| 8 | luma_from_rgb, |
| 9 | luma_from_rgba, |
| 10 | }; |
| 11 | |
| 12 | use oxedyne_fe2o3_core::{ |
| 13 | prelude::*, |
| 14 | test::test_it, |
| 15 | }; |
| 16 | |
| 17 | use std::{ |
| 18 | fs, |
| 19 | path::PathBuf, |
| 20 | }; |
| 21 | |
| 22 | |
| 23 | struct 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. |
| 33 | fn 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 | |
| 83 | fn 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 | |
| 90 | fn 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. |
| 101 | const SUBJECTS: [&str; 3] = ["plasma", "gradient", "shapes"]; |
| 102 | const VARIANTS: [&str; 4] = ["half", "q40", "bright", "png2jpg"]; |
| 103 | |
| 104 | pub 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 | } |