1use crate::{ResourceAnalysis, image_backend::Decoded};
15use serde::{Deserialize, Serialize};
16use std::{
17 collections::BTreeMap,
18 path::{Path, PathBuf},
19};
20
21const GRID: usize = 16;
22const MAX_LUMA_DISTANCE: f32 = 0.022;
24const MAX_ALPHA_DISTANCE: f32 = 0.03;
25const MAX_CELL_DISTANCE: f32 = 0.16;
27const MAX_COLOR_DISTANCE: i32 = 14;
29const MAX_ASPECT_DIFFERENCE: f64 = 0.03;
30const MIN_VARIANCE: f32 = 0.0004;
32const RESIZED_DISTANCE: f32 = 0.012;
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Fingerprint {
38 pub luma: String,
40 pub alpha: String,
42 pub mean_rgb: [u8; 3],
43 pub detailed: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SimilarGroup {
49 pub kind: String,
52 pub members: Vec<usize>,
54 pub redundant_bytes: u64,
56}
57
58fn 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 (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
107fn 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 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
157fn 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 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 && 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
223pub(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 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 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 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 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}