mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
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:
1 parent
c46b2c1082
commit
c98ed0d83b
6 files changed
+406
-4
No files matched your search
@@ -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;
|
||||
";
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user