feat(core): perceptual hashes for finding similar photos

A 64-bit difference hash of each thumbnail, stored in the new
photos.similar_hash column (schema 34), and the queries the similar-photo
finder needs: what is left to hash, progress, and the sets of shots that
look alike. Sets made only of byte-identical copies are left to the
duplicate finder. A refresh keeps a hash unless the photo's bytes changed.

The daemon pass, the control request and the GUI are not built yet; see
PLAN.md.
This commit is contained in:
nvrl committed 2026-09-28 09:16:54 +02:00
1 parent c46b2c1082
commit c98ed0d83b
6 files changed
+406 -4

No files matched your search

+28 -1
View File
@@ -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;
";
+111 -3
View File
@@ -98,6 +98,8 @@ struct Learned {
content_hash: Option<String>,
main_uid: Option<String>,
location: Option<(f64, f64)>,
/// What its thumbnail looks like (see [`crate::similar`]).
similar_hash: Option<i64>,
/// 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<i64>,
@@ -293,7 +295,7 @@ impl Db {
let learned: HashMap<String, Learned> = {
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<f64>>(9)?
.zip(r.get::<_, Option<f64>>(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<Vec<StoredPhoto>> {
let uids: Vec<String> = {
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::<rusqlite::Result<_>>()?
};
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<Vec<Vec<StoredPhoto>>> {
let rows: Vec<(String, u64, Option<String>)> = {
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::<rusqlite::Result<_>>()?
};
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
let found: Vec<Vec<usize>> = 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<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
let mut stored: HashMap<String, StoredPhoto> = self
.photos_by_uid(&uids)?
.into_iter()
.map(|photo| (photo.uid.clone(), photo))
.collect();
let mut sets: Vec<Vec<StoredPhoto>> = found
.iter()
.map(|set| {
let mut photos: Vec<StoredPhoto> = 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;
+70
View File
@@ -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<Vec<&str>> = 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<String> {
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]