Skip to main content

resopt/
similarity.rs

1//! Duplicate and near-duplicate image detection.
2//!
3//! Each decoded image gets a scale-invariant fingerprint: 16×16 area-averaged
4//! grids of its luminance (composited over mid-gray) and of its alpha channel,
5//! plus its mean color. Area averaging makes the grids nearly independent of
6//! pixel dimensions, so images whose grids agree are grouped even when their
7//! sizes differ. (A DCT bit hash was tried first; hard-edged alpha masks made
8//! it unstable across scales.) Intended variants of one
9//! asset (`@2x`/`@3x`, renditions of one image set, Android density or locale
10//! folders of one resource name) are never reported against each other.
11//!
12//! Groups are findings for a person to act on; nothing is merged automatically,
13//! because removing a file means changing the code that names it.
14use crate::{ResourceAnalysis, image_backend::Decoded};
15use serde::{Deserialize, Serialize};
16use std::{
17    collections::BTreeMap,
18    path::{Path, PathBuf},
19};
20
21const GRID: usize = 16;
22/// Largest mean absolute grid difference (0–1) still called the same picture.
23const MAX_LUMA_DISTANCE: f32 = 0.022;
24const MAX_ALPHA_DISTANCE: f32 = 0.03;
25/// No single grid cell may differ by more than this.
26const MAX_CELL_DISTANCE: f32 = 0.16;
27/// Largest per-channel mean color difference on a 0–255 scale.
28const MAX_COLOR_DISTANCE: i32 = 14;
29const MAX_ASPECT_DIFFERENCE: f64 = 0.03;
30/// Below this luminance variance an image is too flat to hash reliably.
31const MIN_VARIANCE: f32 = 0.0004;
32/// Distance at which differently sized images count as one picture resized.
33const RESIZED_DISTANCE: f32 = 0.012;
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Fingerprint {
38    /// Hex-encoded 16×16 luminance grid, one byte per cell.
39    pub luma: String,
40    /// Hex-encoded 16×16 alpha grid; empty for opaque images.
41    pub alpha: String,
42    pub mean_rgb: [u8; 3],
43    /// False for nearly uniform images, which only match exact duplicates.
44    pub detailed: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SimilarGroup {
49    /// `identical` (same bytes), `resized` (same picture, different
50    /// dimensions) or `similar` (near-duplicate).
51    pub kind: String,
52    /// Report indexes, largest file first.
53    pub members: Vec<usize>,
54    /// Bytes beyond the largest member: what keeping one copy would save.
55    pub redundant_bytes: u64,
56}
57
58/// Area-average one channel expression onto a GRID×GRID grid.
59fn downscale(image: &Decoded, sample: impl Fn(&[f32]) -> f32) -> Vec<f32> {
60    let (width, height) = (image.info.width, image.info.height);
61    let mut sums = vec![0.0_f32; GRID * GRID];
62    let mut counts = vec![0_u32; GRID * GRID];
63    for y in 0..height {
64        let gy = y * GRID / height;
65        let row = &image.pixels[y * width * 4..(y + 1) * width * 4];
66        for (x, pixel) in row.as_chunks::<4>().0.iter().enumerate() {
67            let cell = gy * GRID + x * GRID / width;
68            sums[cell] += sample(pixel);
69            counts[cell] += 1;
70        }
71    }
72    // Images smaller than the grid leave empty cells; reuse the nearest source.
73    (0..GRID * GRID)
74        .map(|cell| {
75            if counts[cell] > 0 {
76                return sums[cell] / counts[cell] as f32;
77            }
78            let (gx, gy) = (cell % GRID, cell / GRID);
79            let pixel = ((gy * height / GRID) * width + gx * width / GRID) * 4;
80            sample(&image.pixels[pixel..pixel + 4])
81        })
82        .collect()
83}
84
85fn encode(grid: &[f32]) -> String {
86    grid.iter()
87        .map(|v| format!("{:02x}", (v.clamp(0.0, 1.0) * 255.0).round() as u8))
88        .collect()
89}
90
91fn decode(hex: &str) -> Option<Vec<f32>> {
92    if hex.is_empty() {
93        return Some(vec![1.0; GRID * GRID]);
94    }
95    if hex.len() != GRID * GRID * 2 || !hex.is_ascii() {
96        return None;
97    }
98    (0..GRID * GRID)
99        .map(|cell| {
100            u8::from_str_radix(&hex[cell * 2..cell * 2 + 2], 16)
101                .ok()
102                .map(|v| f32::from(v) / 255.0)
103        })
104        .collect()
105}
106
107/// Mean and largest absolute difference between two grids.
108fn distance(a: &[f32], b: &[f32]) -> (f32, f32) {
109    let (mut total, mut worst) = (0.0_f32, 0.0_f32);
110    for (x, y) in a.iter().zip(b) {
111        let delta = (x - y).abs();
112        total += delta;
113        worst = worst.max(delta);
114    }
115    (total / a.len() as f32, worst)
116}
117
118pub(crate) fn fingerprint(image: &Decoded) -> Option<Fingerprint> {
119    if image.info.width == 0 || image.info.height == 0 || image.pixels.is_empty() {
120        return None;
121    }
122    // Pixels are premultiplied, so compositing over gray is `c + (1 - a) * 0.5`.
123    let luma = downscale(image, |p| {
124        0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + (1.0 - p[3]) * 0.5
125    });
126    let alpha = downscale(image, |p| p[3]);
127    let mean = luma.iter().sum::<f32>() / luma.len() as f32;
128    let variance = luma.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / luma.len() as f32;
129    let opaque = alpha.iter().all(|a| *a >= 0.999);
130    let mut color = [0.0_f64; 3];
131    let mut coverage = 0.0_f64;
132    for pixel in image.pixels.as_chunks::<4>().0 {
133        for (total, value) in color.iter_mut().zip(pixel) {
134            *total += f64::from(*value);
135        }
136        coverage += f64::from(pixel[3]);
137    }
138    let mean_rgb = color.map(|total| {
139        if coverage > 0.0 {
140            (total / coverage * 255.0).round().clamp(0.0, 255.0) as u8
141        } else {
142            0
143        }
144    });
145    Some(Fingerprint {
146        luma: encode(&luma),
147        alpha: if opaque {
148            String::new()
149        } else {
150            encode(&alpha)
151        },
152        mean_rgb,
153        detailed: variance >= MIN_VARIANCE,
154    })
155}
156
157/// Identity of the asset a file is a variant of. Files sharing it are intended
158/// to look alike and are never reported against each other.
159fn variant_key(path: &Path) -> PathBuf {
160    let parent = path.parent().unwrap_or(Path::new(""));
161    if parent
162        .extension()
163        .is_some_and(|e| e == "imageset" || e == "appiconset")
164    {
165        return parent.to_path_buf();
166    }
167    let stem = path
168        .file_name()
169        .map(|name| name.to_string_lossy().to_string())
170        .unwrap_or_default();
171    let stem = stem.split('.').next().unwrap_or_default();
172    let stem = stem
173        .strip_suffix("@2x")
174        .or_else(|| stem.strip_suffix("@3x"))
175        .unwrap_or(stem)
176        .to_ascii_lowercase();
177    match crate::android::classify(path) {
178        // One resource name across density, locale and RTL folders of a module.
179        Some(android) if android.area == "res" => android
180            .module
181            .join(android.source_set)
182            .join(android.res_type.unwrap_or_default())
183            .join(stem),
184        _ => parent.join(stem),
185    }
186}
187
188struct Entry {
189    index: usize,
190    bytes: u64,
191    sha256: Option<String>,
192    print: Fingerprint,
193    luma: Vec<f32>,
194    alpha: Vec<f32>,
195    aspect: f64,
196    dimensions: (usize, usize),
197    variant: PathBuf,
198}
199
200fn alike(a: &Entry, b: &Entry) -> bool {
201    if a.variant == b.variant {
202        return false;
203    }
204    if a.sha256.is_some() && a.sha256 == b.sha256 {
205        return true;
206    }
207    a.print.detailed
208        && b.print.detailed
209        && (a.aspect - b.aspect).abs() <= MAX_ASPECT_DIFFERENCE * a.aspect.max(b.aspect)
210        // Cheapest checks first: this runs for every pair of images.
211        && a.print
212            .mean_rgb
213            .iter()
214            .zip(b.print.mean_rgb)
215            .all(|(x, y)| (i32::from(*x) - i32::from(y)).abs() <= MAX_COLOR_DISTANCE)
216        && {
217            let (mean, worst) = distance(&a.luma, &b.luma);
218            mean <= MAX_LUMA_DISTANCE && worst <= MAX_CELL_DISTANCE
219        }
220        && distance(&a.alpha, &b.alpha).0 <= MAX_ALPHA_DISTANCE
221}
222
223/// Group analyzed images that show the same picture.
224pub(crate) fn group(resources: &[ResourceAnalysis]) -> Vec<SimilarGroup> {
225    let entries: Vec<Entry> = resources
226        .iter()
227        .enumerate()
228        .filter_map(|(index, resource)| {
229            let print = resource.fingerprint.clone()?;
230            let image = resource.image.as_ref()?;
231            Some(Entry {
232                index,
233                bytes: resource.resource.bytes,
234                sha256: resource.sha256.clone(),
235                luma: decode(&print.luma)?,
236                alpha: decode(&print.alpha)?,
237                aspect: image.width as f64 / image.height.max(1) as f64,
238                dimensions: (image.width, image.height),
239                variant: variant_key(&resource.resource.path),
240                print,
241            })
242        })
243        .collect();
244    // Union-find over pairwise matches; the color check rejects most of the
245    // few million pairs before any grid is compared.
246    let mut parent: Vec<usize> = (0..entries.len()).collect();
247    fn find(parent: &mut [usize], mut node: usize) -> usize {
248        while parent[node] != node {
249            parent[node] = parent[parent[node]];
250            node = parent[node];
251        }
252        node
253    }
254    for a in 0..entries.len() {
255        for b in a + 1..entries.len() {
256            if alike(&entries[a], &entries[b]) {
257                let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
258                parent[ra] = rb;
259            }
260        }
261    }
262    let mut clusters: BTreeMap<usize, Vec<&Entry>> = BTreeMap::new();
263    for (position, entry) in entries.iter().enumerate() {
264        clusters
265            .entry(find(&mut parent, position))
266            .or_default()
267            .push(entry);
268    }
269    let mut groups: Vec<SimilarGroup> = clusters
270        .into_values()
271        .filter(|members| members.len() > 1)
272        .map(|mut members| {
273            members.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.index.cmp(&b.index)));
274            let first = members[0];
275            let identical = members
276                .iter()
277                .all(|m| m.sha256.is_some() && m.sha256 == first.sha256);
278            let resized = members.iter().any(|m| m.dimensions != first.dimensions)
279                && members
280                    .iter()
281                    .all(|m| distance(&m.luma, &first.luma).0 <= RESIZED_DISTANCE);
282            SimilarGroup {
283                kind: if identical {
284                    "identical"
285                } else if resized {
286                    "resized"
287                } else {
288                    "similar"
289                }
290                .into(),
291                redundant_bytes: members.iter().skip(1).map(|m| m.bytes).sum(),
292                members: members.iter().map(|m| m.index).collect(),
293            }
294        })
295        .collect();
296    groups.sort_by(|a, b| {
297        b.redundant_bytes
298            .cmp(&a.redundant_bytes)
299            .then(a.members.cmp(&b.members))
300    });
301    groups
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::{ImageInfo, Resource};
308
309    fn picture(width: usize, height: usize, paint: impl Fn(f32, f32) -> [f32; 4]) -> Decoded {
310        let mut pixels = Vec::with_capacity(width * height * 4);
311        for y in 0..height {
312            for x in 0..width {
313                let [r, g, b, a] = paint(
314                    (x as f32 + 0.5) / width as f32,
315                    (y as f32 + 0.5) / height as f32,
316                );
317                pixels.extend([r * a, g * a, b * a, a]);
318            }
319        }
320        Decoded {
321            info: ImageInfo {
322                decoder_type: "test".into(),
323                width,
324                height,
325                frames: 1,
326                bits_per_component: 8,
327                orientation: 1,
328                transparent_pixels: 0,
329                has_transparent_pixels: false,
330            },
331            pixels,
332        }
333    }
334
335    /// A badge: colored disc with a lighter stripe, transparent outside.
336    fn badge(tint: [f32; 3]) -> impl Fn(f32, f32) -> [f32; 4] {
337        move |x, y| {
338            let inside = (x - 0.5).powi(2) + (y - 0.5).powi(2) < 0.2;
339            let stripe = (y - 0.35).abs() < 0.08 && x > 0.3;
340            let shade = if stripe { 1.0 } else { 0.55 + 0.4 * x };
341            [
342                tint[0] * shade,
343                tint[1] * shade,
344                tint[2] * shade,
345                if inside { 1.0 } else { 0.0 },
346            ]
347        }
348    }
349
350    fn row(path: &str, image: &Decoded, sha: &str) -> ResourceAnalysis {
351        let mut row = ResourceAnalysis::new(&Resource::for_tests(path, "png"), "inspected");
352        row.resource.bytes = (image.info.width * image.info.height) as u64;
353        row.sha256 = Some(sha.repeat(64));
354        row.image = Some(image.info.clone());
355        row.fingerprint = fingerprint(image);
356        row
357    }
358
359    #[test]
360    fn the_same_picture_at_another_size_is_found_and_variants_are_not() {
361        let red = badge([0.9, 0.2, 0.2]);
362        let large = picture(300, 300, &red);
363        let small = picture(96, 96, &red);
364        let rows = vec![
365            row("Feature/A/badge_big.png", &large, "a"),
366            row("Feature/B/medal.png", &small, "b"),
367            // Intended scale variants of one asset.
368            row("Feature/C/icon@2x.png", &small, "c"),
369            row("Feature/C/icon@3x.png", &large, "d"),
370        ];
371        let groups = group(&rows);
372        assert_eq!(groups.len(), 1, "{groups:?}");
373        assert_eq!(groups[0].kind, "resized");
374        // All four show one picture, but @2x/@3x alone would not be a finding.
375        assert_eq!(groups[0].members.len(), 4);
376        let only_variants = group(&rows[2..]);
377        assert!(only_variants.is_empty(), "{only_variants:?}");
378    }
379
380    #[test]
381    fn identical_bytes_are_reported_as_identical_with_redundant_bytes() {
382        let image = picture(64, 64, badge([0.2, 0.5, 0.9]));
383        let rows = vec![
384            row("a/one.png", &image, "a"),
385            row("b/two.png", &image, "a"),
386            row("c/three.png", &image, "a"),
387        ];
388        let groups = group(&rows);
389        assert_eq!(groups[0].kind, "identical");
390        assert_eq!(groups[0].redundant_bytes, 2 * 64 * 64);
391    }
392
393    #[test]
394    fn different_tint_shape_or_alpha_is_not_a_duplicate() {
395        let base = picture(128, 128, badge([0.9, 0.2, 0.2]));
396        let blue = picture(128, 128, badge([0.2, 0.3, 0.9]));
397        let square = picture(128, 128, |x, y| {
398            let inside = (x - 0.5).abs() < 0.4 && (y - 0.5).abs() < 0.4;
399            [0.9 * x, 0.2, 0.2 * y, if inside { 1.0 } else { 0.0 }]
400        });
401        let opaque = picture(128, 128, |x, y| {
402            let [r, g, b, _] = badge([0.9, 0.2, 0.2])(x, y);
403            [r, g, b, 1.0]
404        });
405        let rows = vec![
406            row("a/base.png", &base, "a"),
407            row("b/blue.png", &blue, "b"),
408            row("c/square.png", &square, "c"),
409            row("d/opaque.png", &opaque, "d"),
410        ];
411        assert!(group(&rows).is_empty(), "{:?}", group(&rows));
412    }
413
414    #[test]
415    fn flat_images_only_match_by_exact_bytes() {
416        let white = picture(40, 40, |_, _| [1.0, 1.0, 1.0, 1.0]);
417        let nearly = picture(80, 80, |_, _| [0.99, 1.0, 1.0, 1.0]);
418        assert!(!fingerprint(&white).unwrap().detailed);
419        let rows = vec![row("a/w.png", &white, "a"), row("b/n.png", &nearly, "b")];
420        assert!(group(&rows).is_empty());
421        let same = vec![row("a/w.png", &white, "a"), row("b/w.png", &white, "a")];
422        assert_eq!(group(&same)[0].kind, "identical");
423    }
424
425    #[test]
426    fn android_density_and_locale_folders_of_one_name_are_variants() {
427        let image = picture(64, 64, badge([0.3, 0.8, 0.4]));
428        let big = picture(128, 128, badge([0.3, 0.8, 0.4]));
429        let rows = vec![
430            row("app/src/main/res/drawable-xhdpi/ic_ok.png", &image, "a"),
431            row("app/src/main/res/drawable-xxxhdpi/ic_ok.png", &big, "b"),
432            row(
433                "app/src/main/res/drawable-ldrtl-xhdpi/ic_ok.png",
434                &image,
435                "a",
436            ),
437        ];
438        assert!(group(&rows).is_empty());
439        let mut with_copy = rows;
440        with_copy.push(row(
441            "module/room/src/main/res/drawable-xhdpi/ic_done.png",
442            &image,
443            "a",
444        ));
445        assert_eq!(group(&with_copy).len(), 1);
446    }
447
448    #[test]
449    fn tiny_images_and_malformed_fingerprints_are_handled() {
450        let tiny = picture(5, 3, |x, y| [x, y, 0.5, 1.0]);
451        let print = fingerprint(&tiny).unwrap();
452        assert_eq!(print.luma.len(), GRID * GRID * 2);
453        assert_eq!(print.alpha, "");
454        let mut broken = row("a/x.png", &tiny, "a");
455        broken.fingerprint.as_mut().unwrap().luma = "zz".into();
456        assert!(group(&[broken, row("b/y.png", &tiny, "b")]).is_empty());
457    }
458}