diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 3dee5cb..4ca2fb6 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -9,7 +9,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 33; +pub(super) const SCHEMA_VERSION: i64 = 34; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -300,6 +300,22 @@ impl Db { tx.execute_batch(MIGRATION_V33)?; } } + if current < 34 { + // Same guards as V26-V33. + let has_column: bool = tx.query_row( + "SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + let has_photos: bool = tx.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'", + [], + |row| row.get::<_, i64>(0), + )? > 0; + if has_photos && !has_column { + tx.execute_batch(MIGRATION_V34)?; + } + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -1006,3 +1022,14 @@ ALTER TABLE photos ADD COLUMN place_id INTEGER; CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id); UPDATE photos SET resolved_at = NULL; "; + +/// Schema v34: what each photo looks like, for finding similar ones. +/// +/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see +/// `crate::similar`): resized and re-encoded copies of one picture land a few +/// bits apart, where the server's `content_hash` only matches byte-identical +/// files. `NULL` until the daemon has hashed the thumbnail, which it does as +/// thumbnails arrive and when the similar-photo finder asks for the rest. +const MIGRATION_V34: &str = " +ALTER TABLE photos ADD COLUMN similar_hash INTEGER; +"; diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index eaa8fb1..003561c 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -98,6 +98,8 @@ struct Learned { content_hash: Option, main_uid: Option, location: Option<(f64, f64)>, + /// What its thumbnail looks like (see [`crate::similar`]). + similar_hash: Option, /// When this photo's node was last resolved, kept so a refresh that skips it /// does not make it look unresolved again. resolved_at: Option, @@ -293,7 +295,7 @@ impl Db { let learned: HashMap = { let mut stmt = tx.prepare( "SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \ - main_uid, resolved_at, latitude, longitude FROM photos", + main_uid, resolved_at, latitude, longitude, similar_hash FROM photos", )?; let rows = stmt.query_map([], |r| { Ok(( @@ -310,6 +312,7 @@ impl Db { location: r .get::<_, Option>(9)? .zip(r.get::<_, Option>(10)?), + similar_hash: r.get(11)?, }, )) })?; @@ -332,6 +335,10 @@ impl Db { carried.name = row.name.clone(); carried.media_type = row.media_type.clone(); carried.favorite = row.favorite.unwrap_or(false); + // New bytes may show another picture: hash it again. + if row.content_hash != carried.content_hash { + carried.similar_hash = None; + } carried.content_hash = row.content_hash.clone(); carried.main_uid = row.main_uid.clone(); carried.location = row.location; @@ -373,9 +380,9 @@ impl Db { "INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite, content_hash, main_uid, group_key, resolved_at, latitude, longitude, - place_id) + place_id, similar_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, - ?16)", + ?16, ?17)", )?; for (seq, ((row, carried, kind), group_key)) in resolved.iter().zip(group_keys.iter()).enumerate() @@ -400,6 +407,7 @@ impl Db { .location .and_then(|(lat, lon)| crate::places::nearest(lat, lon)) .map(|city| city.id), + carried.similar_hash, ])?; } } @@ -771,6 +779,106 @@ impl Db { Ok(sets) } + /// Shots whose look hasn't been hashed yet, by the uid of the photo the + /// grid shows for each, newest first, at most `limit`. Videos, and photos + /// with no thumbnail to hash, are left out. A shot counts as hashed once + /// any of its files is, since they all show one picture. + pub fn photos_unhashed(&self, limit: usize) -> Result> { + let uids: Vec = { + let conn = self.read(); + let mut stmt = conn.prepare( + "SELECT COALESCE(group_key, uid) AS k FROM photos \ + GROUP BY k \ + HAVING MAX(similar_hash) IS NULL \ + AND MAX(kind != ?1 AND thumb_state != ?2) \ + ORDER BY MAX(capture_time) DESC LIMIT ?3", + )?; + stmt.query_map( + params![ + crate::control::PhotoKind::Video.as_i64(), + THUMB_NONE, + limit as i64 + ], + |r| r.get(0), + )? + .collect::>()? + }; + self.photos_by_uid(&uids) + } + + /// Record the look of photo `uid`: its difference hash, or + /// [`crate::similar::NO_HASH`] when its thumbnail gave none. + pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE photos SET similar_hash = ?2 WHERE uid = ?1", + params![uid, hash as i64], + )?; + Ok(()) + } + + /// How many shots have been hashed, out of how many could be. + pub fn similar_progress(&self) -> Result<(usize, usize)> { + let conn = self.read(); + let (hashed, total): (i64, i64) = conn.query_row( + "SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \ + (SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \ + GROUP BY COALESCE(group_key, uid) \ + HAVING MAX(similar_hash) IS NOT NULL \ + OR MAX(kind != ?1 AND thumb_state != ?2))", + params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + Ok((hashed.max(0) as usize, total.max(0) as usize)) + } + + /// Sets of shots that look alike (see [`crate::similar`]), one photo per + /// shot in capture order, and the sets newest first. A set whose photos + /// are all byte-identical copies is left to [`Self::photo_duplicates`]. + pub fn photo_similar(&self) -> Result>> { + let rows: Vec<(String, u64, Option)> = { + let conn = self.read(); + let mut stmt = conn.prepare( + "SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \ + MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \ + FROM photos GROUP BY k \ + HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1", + )?; + stmt.query_map(params![crate::similar::NO_HASH as i64], |r| { + Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?)) + })? + .collect::>()? + }; + let hashes: Vec = rows.iter().map(|(_, hash, _)| *hash).collect(); + let found: Vec> = crate::similar::sets(&hashes) + .into_iter() + .filter(|set| { + let first = &rows[set[0]].2; + first.is_none() || set.iter().any(|&i| rows[i].2 != *first) + }) + .collect(); + let uids: Vec = found.iter().flatten().map(|&i| rows[i].0.clone()).collect(); + let mut stored: HashMap = self + .photos_by_uid(&uids)? + .into_iter() + .map(|photo| (photo.uid.clone(), photo)) + .collect(); + let mut sets: Vec> = found + .iter() + .map(|set| { + let mut photos: Vec = set + .iter() + .filter_map(|&i| stored.remove(&rows[i].0)) + .collect(); + photos.sort_by_key(|p| p.capture_time); + photos + }) + .filter(|set| set.len() > 1) + .collect(); + sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max())); + Ok(sets) + } + /// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`. pub fn photos_counts(&self) -> Result<(usize, usize, usize)> { use crate::control::PhotoKind; diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 5933687..e6af0e3 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -406,6 +406,76 @@ fn duplicates_list_the_copy_to_keep_first() { assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]); } +/// Shots that look alike form a set, one photo per shot, while a set of +/// byte-identical copies is left to the duplicate finder. +#[test] +fn similar_photos_group_by_look_and_skip_exact_copies() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + hashed("big", 500, "IMG_1.JPG", "h1"), + hashed("small", 400, "IMG_1-small.JPG", "h2"), + hashed("copy", 300, "IMG_2.JPG", "h3"), + hashed("copy2", 200, "IMG_2(1).JPG", "h3"), + hashed("other", 100, "IMG_3.JPG", "h4"), + hashed("flat", 50, "IMG_4.JPG", "h5"), + ]) + .unwrap(); + let look = 0x0f0f_3c3c_5a5a_9696_u64; + db.photo_set_similar_hash("big", look).unwrap(); + db.photo_set_similar_hash("small", look ^ 0b101).unwrap(); + db.photo_set_similar_hash("copy", !look).unwrap(); + db.photo_set_similar_hash("copy2", !look).unwrap(); + db.photo_set_similar_hash("other", look ^ 0xffff_ffff) + .unwrap(); + db.photo_set_similar_hash("flat", crate::similar::NO_HASH) + .unwrap(); + + let sets = db.photo_similar().unwrap(); + let uids: Vec> = sets + .iter() + .map(|set| set.iter().map(|p| p.uid.as_str()).collect()) + .collect(); + assert_eq!(uids, [vec!["small", "big"]]); + assert_eq!(db.similar_progress().unwrap(), (6, 6)); +} + +/// What is left to hash skips videos and hashed shots, and a hash survives a +/// refresh unless the photo's bytes changed. +#[test] +fn similar_hashes_are_kept_until_the_photo_changes() { + let db = Db::open_in_memory().unwrap(); + let resolved = |uid: &str, hash: &str| TimelineRow { + resolved_at: Some(1), + ..hashed(uid, 100, &format!("{uid}.JPG"), hash) + }; + db.photos_replace(&[ + resolved("a", "h1"), + resolved("b", "h2"), + hashed("clip", 50, "clip.mp4", "h3"), + ]) + .unwrap(); + db.photo_set_similar_hash("a", 7).unwrap(); + db.photo_set_similar_hash("b", 9).unwrap(); + let left = |db: &Db| -> Vec { + db.photos_unhashed(10) + .unwrap() + .into_iter() + .map(|p| p.uid) + .collect() + }; + assert!(left(&db).is_empty()); + assert_eq!(db.similar_progress().unwrap(), (2, 2)); + + db.photos_replace(&[ + resolved("a", "h1"), + resolved("b", "h2-edited"), + hashed("clip", 50, "clip.mp4", "h3"), + ]) + .unwrap(); + assert_eq!(left(&db), ["b"]); + assert_eq!(db.similar_progress().unwrap(), (1, 2)); +} + /// Trashing the file a group is shown as leaves the rest of the group on the /// timeline instead of pointing at a photo that is gone. #[test] diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 86e7a9f..42abbe5 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod sdkcache; pub mod search; pub mod service; pub mod shell; +pub mod similar; pub mod syncignore; pub mod takeout; pub mod tray; diff --git a/crates/pdfs-core/src/similar.rs b/crates/pdfs-core/src/similar.rs new file mode 100644 index 0000000..abc2d15 --- /dev/null +++ b/crates/pdfs-core/src/similar.rs @@ -0,0 +1,193 @@ +//! Telling photos apart by what they show rather than by their bytes. +//! +//! The server's content hash only matches byte-identical files. A photo that +//! was resized, re-encoded or sent through a messenger is another file, so the +//! similar-photo finder hashes each thumbnail instead: a 64-bit difference +//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two +//! copies of one picture land a few bits apart, and different pictures about +//! half of the bits apart. + +/// Hashes this many bits apart or fewer show the same picture. +pub const SIMILAR_BITS: u32 = 6; + +/// Stored for a photo whose thumbnail was looked at and gave no usable hash +/// (it couldn't be decoded, or is one flat colour), so it isn't looked at +/// again. A real hash is never 0: a picture that would hash to 0 has no +/// brightness changes, which [`difference_hash`] turns down as flat. +pub const NO_HASH: u64 = 0; + +/// Samples of the grid that differ by less than this in brightness, all of +/// them, make a flat picture — a black frame, a blank page — whose hash is +/// noise and would match every other flat picture. +const FLAT: u8 = 8; + +/// The difference hash of an 8-bit greyscale image `width` px wide, rows +/// stored one after another in `luma`. `None` for an empty or flat image. +pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option { + if width == 0 || height == 0 || luma.len() < width * height { + return None; + } + // Box-average down to 9×8, so every source pixel counts once and the hash + // doesn't depend on which pixels a nearest-neighbour pick happens to hit. + let mut grid = [[0u8; 9]; 8]; + for (gy, row) in grid.iter_mut().enumerate() { + let (y0, y1) = span(gy, 8, height); + for (gx, cell) in row.iter_mut().enumerate() { + let (x0, x1) = span(gx, 9, width); + let mut sum = 0u64; + for y in y0..y1 { + sum += luma[y * width + x0..y * width + x1] + .iter() + .map(|&v| u64::from(v)) + .sum::(); + } + *cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8; + } + } + let (min, max) = grid + .iter() + .flatten() + .fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v))); + if max - min < FLAT { + return None; + } + let mut hash = 0u64; + for row in &grid { + for pair in row.windows(2) { + hash = hash << 1 | u64::from(pair[0] < pair[1]); + } + } + (hash != NO_HASH).then_some(hash) +} + +/// The source pixels `from..to` that cell `i` of `cells` covers along an edge +/// of `len` px; never empty, even for an edge shorter than the grid. +fn span(i: usize, cells: usize, len: usize) -> (usize, usize) { + let from = (i * len / cells).min(len - 1); + let to = ((i + 1) * len / cells).max(from + 1); + (from, to) +} + +/// Whether two hashes show the same picture. +pub fn alike(a: u64, b: u64) -> bool { + (a ^ b).count_ones() <= SIMILAR_BITS +} + +/// Group `hashes` into sets of alike ones, by index, each set in index order +/// and the sets by their first index. A hash alike to none other is in no +/// set. Alike is taken as catching: a burst of shots each a little different +/// from the last is one set. +pub fn sets(hashes: &[u64]) -> Vec> { + // Two hashes at most SIMILAR_BITS apart agree on at least one of their + // eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some + // byte are compared, not every pair of the library. + const _: () = assert!(SIMILAR_BITS < 8); + let mut parent: Vec = (0..hashes.len()).collect(); + fn root(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + for byte in 0..8 { + let mut buckets: Vec> = vec![Vec::new(); 256]; + for (i, &hash) in hashes.iter().enumerate() { + buckets[(hash >> (byte * 8)) as u8 as usize].push(i); + } + for bucket in &buckets { + for (n, &a) in bucket.iter().enumerate() { + for &b in &bucket[n + 1..] { + if alike(hashes[a], hashes[b]) { + let (ra, rb) = (root(&mut parent, a), root(&mut parent, b)); + if ra != rb { + parent[ra.max(rb)] = ra.min(rb); + } + } + } + } + } + } + let mut by_root: std::collections::BTreeMap> = Default::default(); + for i in 0..hashes.len() { + let r = root(&mut parent, i); + by_root.entry(r).or_default().push(i); + } + by_root.into_values().filter(|set| set.len() > 1).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 90×80 picture: a bright disc on a gradient. + fn picture(width: usize, height: usize) -> Vec { + let mut luma = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64); + let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2; + let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy }; + luma.push(v as u8); + } + } + luma + } + + #[test] + fn a_resized_copy_hashes_alike() { + let big = difference_hash(900, 800, &picture(900, 800)).unwrap(); + let small = difference_hash(91, 79, &picture(91, 79)).unwrap(); + assert!( + alike(big, small), + "{} bits apart", + (big ^ small).count_ones() + ); + } + + #[test] + fn a_different_picture_hashes_apart() { + let one = difference_hash(90, 80, &picture(90, 80)).unwrap(); + let mirrored: Vec = picture(90, 80) + .chunks(90) + .flat_map(|row| row.iter().rev().copied().collect::>()) + .collect(); + let other = difference_hash(90, 80, &mirrored).unwrap(); + assert!(!alike(one, other)); + } + + #[test] + fn a_flat_picture_has_no_hash() { + assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None); + assert_eq!(difference_hash(0, 0, &[]), None); + assert_eq!(difference_hash(4, 4, &[0; 3]), None); + } + + #[test] + fn a_picture_smaller_than_the_grid_still_hashes() { + let luma: Vec = (0..4 * 3).map(|i| (i * 20) as u8).collect(); + assert!(difference_hash(4, 3, &luma).is_some()); + } + + #[test] + fn alike_hashes_form_sets_and_catch_on() { + let a = 0x0123_4567_89ab_cdef_u64; + let hashes = [ + a, + !a, + a ^ 0b111, + a ^ 0b111 ^ (0b1111 << 40), + 0x5555_0000_ffff_aaaa, + ]; + assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]); + } + + #[test] + fn hashes_apart_in_most_bytes_are_still_found() { + // One bit apart in each of six bytes: six bits in all, and only the + // top two bytes agree. + let a = 1u64 << 63; + let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8)); + assert_eq!(sets(&[a, b]), vec![vec![0, 1]]); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 109a336..d994029 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,9 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +Schema: **34**. Photos gain a `similar_hash` column for the coming similar-photo finder. It +stays empty until the daemon hashes thumbnails, so updating reads and downloads nothing. + ## [2.3.0] — 2026-09-28 Schema: **33**. Photos gain their location; the first timeline refresh after updating reads