Keep the face, not just a way to find it again
A face was drawn by decoding the 1024px proxy it was found on and cutting the box out again, every time the People screen opened. That made the screen a derivative of the thumbnail cache: evict a proxy — which the cache may do at any moment — and the cell goes blank, with no way back short of re-fetching the original over the network and re-detecting it. It also cost a full JPEG decode per image, per visit, to show a 96px cell. So the crop is cut once, when the pixels are already in hand at detection time, and kept. A 160px JPEG is a few KB against the ~250 KB proxy it replaces reading. Where it lives is the interesting part. The catalog snapshot is uploaded *whole* on every sync and downloaded by every device, so a crop column there would put tens of MB on every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound, and the reason bulk per-face data lives in shards already. Crops therefore travel in the face shards, beside the embeddings, and `snapshot_for_upload` strips them from the copy it writes. Nothing reads a crop out of a merged remote catalog — the merge touches collections and keywords only — so a receiving device loses nothing. A shard carrying crops holds around 3,500 faces rather than 22,000, which is the price of a second device showing People immediately instead of re-fetching every proxy. The column is nullable and the reader falls back to the proxy, so a face indexed before this still works and the next indexing pass fills it in. V10 also adds `people.ignored`, for a person the user has looked at and does not want to identify. Most clusters in a real library are strangers — passers-by, other people's guests, a face on a poster — and there is no way to tell "not yet looked at" from "looked at, don't care" without recording the second. It is a column rather than a deletion because a deleted cluster comes straight back on the next Regroup: the faces are still there and still similar, and nothing short of remembering the judgement survives re-clustering. Same argument `face_person_rejected` makes one level down. And `prune_empty_unnamed`, for what clustering leaves behind. Regroup creates a person per unanchored group and never removed the previous run's now-empty ones, so pressing it twice added a rail entry per group it no longer believed in. Named people are never touched however empty — a name is user data — nor is a merge tombstone, which must outlive its faces to keep redirecting. 298 tests pass, including that the snapshot carries no crops while the live catalog keeps them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,16 @@ pub const SHARD_MAX_BYTES: u64 = dr_thumbs::SHARD_MAX_BYTES;
|
||||
/// the file after each insert would mean a `VACUUM` to get an honest answer.
|
||||
const BYTES_PER_FACE: u64 = 1024 + 40 + 64;
|
||||
|
||||
/// Bytes a stored crop occupies, near enough to bound a shard by.
|
||||
///
|
||||
/// Measured rather than assumed: a 160 px JPEG of a face at quality 78 runs
|
||||
/// 4-7 KB, and the cap has to be set from the top of that range or a shard
|
||||
/// overshoots. It dominates [`BYTES_PER_FACE`] roughly five to one, so a shard
|
||||
/// that carries crops holds around 3,500 faces where one carrying only
|
||||
/// embeddings held 22,000 -- the cost of a second device showing People
|
||||
/// immediately instead of re-fetching every proxy.
|
||||
const BYTES_PER_CROP: u64 = 7 * 1024;
|
||||
|
||||
/// One face as it travels between devices.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SharedFace {
|
||||
@@ -66,6 +76,13 @@ pub struct SharedFace {
|
||||
pub confidence: f32,
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// The face cut out and encoded, or empty where none was kept.
|
||||
///
|
||||
/// Travels with the face rather than in the catalog snapshot, which is the
|
||||
/// same bargain the embedding makes and for the same reason: the snapshot
|
||||
/// is uploaded whole on every sync, and a shard is written once and
|
||||
/// downloaded once.
|
||||
pub crop: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A store of face shards, beside the thumbnail store.
|
||||
@@ -135,7 +152,11 @@ impl FaceShardStore {
|
||||
source_edge: u32,
|
||||
faces: &[SharedFace],
|
||||
) -> Result<u32, CatalogError> {
|
||||
let incoming = faces.len() as u64 * BYTES_PER_FACE + 64;
|
||||
let incoming = faces
|
||||
.iter()
|
||||
.map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP })
|
||||
.sum::<u64>()
|
||||
+ 64;
|
||||
let shard = self.active_shard(incoming)?;
|
||||
let conn = self.open_shard(shard, true)?;
|
||||
|
||||
@@ -150,8 +171,8 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
embedding, crop_px, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
rusqlite::params![
|
||||
f.file_id as i64,
|
||||
f.model_id,
|
||||
@@ -163,6 +184,7 @@ impl FaceShardStore {
|
||||
f.confidence as f64,
|
||||
f.embedding,
|
||||
f.crop_px as f64,
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -314,7 +336,7 @@ impl FaceShardStore {
|
||||
}
|
||||
let mut fq = src.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px
|
||||
embedding, crop_px, crop
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -355,7 +377,7 @@ impl FaceShardStore {
|
||||
let Some(edge) = edge else { return Ok(None) };
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = q
|
||||
@@ -398,7 +420,7 @@ pub fn export_to_shards(
|
||||
continue;
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop
|
||||
FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -414,6 +436,7 @@ pub fn export_to_shards(
|
||||
confidence: r.get::<_, f64>(5)? as f32,
|
||||
embedding: r.get(6)?,
|
||||
crop_px: r.get::<_, f64>(7)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -475,6 +498,10 @@ pub fn import_from_shards(
|
||||
embedding: f.embedding,
|
||||
crop_px: f.crop_px,
|
||||
model_id: f.model_id,
|
||||
// A peer that indexed before crops existed sends none, and the
|
||||
// reader falls back to the proxy exactly as it does for a face
|
||||
// this device indexed that early.
|
||||
crop: f.crop,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -514,6 +541,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
confidence: r.get::<_, f64>(7)? as f32,
|
||||
embedding: r.get(8)?,
|
||||
crop_px: r.get::<_, f64>(9)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -585,7 +613,10 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
landmarks BLOB NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL,
|
||||
crop_px REAL NOT NULL
|
||||
crop_px REAL NOT NULL,
|
||||
-- The face, cut out. NULL where the face was found before crops were kept,
|
||||
-- or adopted from a peer that did not have one.
|
||||
crop BLOB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
|
||||
|
||||
@@ -635,6 +666,7 @@ mod tests {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,6 +874,7 @@ mod catalog_round_trip {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,18 @@ pub struct DetectedFace {
|
||||
/// Which model produced the embedding. Comparing across models is the one
|
||||
/// mistake that yields plausible garbage rather than an error.
|
||||
pub model_id: String,
|
||||
/// The face itself, cut out and encoded, ready to draw.
|
||||
///
|
||||
/// Cut at detection time because that is the one moment the pixels are
|
||||
/// already in memory. The alternative -- and what this replaced -- is
|
||||
/// re-decoding the whole proxy and cutting the box out again every time
|
||||
/// the People screen opens, which makes the screen a derivative of a cache
|
||||
/// that is entitled to evict anything at any time.
|
||||
///
|
||||
/// Empty is allowed and means "not cut": a caller with only a box and an
|
||||
/// embedding, such as a face adopted from a peer's shard that predates
|
||||
/// crops, stores nothing here and the reader falls back to the proxy.
|
||||
pub crop: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A stored face, with whatever identity it has acquired.
|
||||
@@ -101,6 +113,13 @@ pub struct Person {
|
||||
pub confirmed_faces: u64,
|
||||
/// Faces the system suggests and the user has not ruled on.
|
||||
pub suggested_faces: u64,
|
||||
/// The user has looked at this group and does not want to identify it.
|
||||
///
|
||||
/// Distinct from an empty name, which means *not yet looked at*. Most
|
||||
/// clusters in a real library are strangers, and without somewhere to put
|
||||
/// that judgement the screen never gets shorter however much work the user
|
||||
/// does on it.
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
/// The FR-CULL-9 calibration, re-exported from where it is fitted.
|
||||
@@ -165,8 +184,8 @@ pub fn record_detections(
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||||
embedding, crop_px, model_id, detected_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
embedding, crop_px, model_id, detected_at, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
f.x as f64,
|
||||
@@ -179,6 +198,10 @@ pub fn record_detections(
|
||||
f.crop_px as f64,
|
||||
f.model_id,
|
||||
now,
|
||||
// NULL rather than an empty blob, so "no crop" is one state in
|
||||
// the database rather than two the readers each have to know
|
||||
// about.
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
],
|
||||
)?;
|
||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||
@@ -415,7 +438,8 @@ pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT p.id, p.uuid, p.name,
|
||||
COALESCE(SUM(fp.confirmed = 1), 0),
|
||||
COALESCE(SUM(fp.confirmed = 0), 0)
|
||||
COALESCE(SUM(fp.confirmed = 0), 0),
|
||||
p.ignored
|
||||
FROM people p
|
||||
LEFT JOIN face_person fp ON fp.person_id = p.id
|
||||
WHERE p.merged_into IS NULL
|
||||
@@ -429,6 +453,7 @@ pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
||||
name: r.get(2)?,
|
||||
confirmed_faces: r.get::<_, i64>(3)? as u64,
|
||||
suggested_faces: r.get::<_, i64>(4)? as u64,
|
||||
ignored: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||
@@ -732,6 +757,114 @@ pub fn delete_all_face_data(conn: &Connection) -> Result<u64, CatalogError> {
|
||||
Ok(faces as u64)
|
||||
}
|
||||
|
||||
/// The stored crops for one person's faces.
|
||||
///
|
||||
/// Read separately from [`for_person`] rather than joined onto it, because the
|
||||
/// boxes are wanted in several places that do not draw anything — clustering
|
||||
/// anchors, the develop overlay, the segmentation naming pass — and dragging a
|
||||
/// few hundred KB of JPEG through those would be pure waste.
|
||||
///
|
||||
/// A face with no stored crop is simply absent from the map; the caller falls
|
||||
/// back to cutting one out of the proxy.
|
||||
pub fn crops_for_person(
|
||||
conn: &Connection,
|
||||
person: PersonId,
|
||||
include_suggested: bool,
|
||||
) -> Result<std::collections::HashMap<FaceId, Vec<u8>>, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.crop
|
||||
FROM faces f
|
||||
JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||||
AND f.crop IS NOT NULL",
|
||||
)?;
|
||||
let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| {
|
||||
Ok((FaceId(r.get::<_, i64>(0)? as u64), r.get::<_, Vec<u8>>(1)?))
|
||||
})?;
|
||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// One face's stored crop, if it has one.
|
||||
pub fn crop(conn: &Connection, face: FaceId) -> Result<Option<Vec<u8>>, CatalogError> {
|
||||
conn.query_row(
|
||||
"SELECT crop FROM faces WHERE id = ?1",
|
||||
[face.0 as i64],
|
||||
|r| r.get::<_, Option<Vec<u8>>>(0),
|
||||
)
|
||||
.optional()
|
||||
.map(Option::flatten)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// How many of a model's faces still have no stored crop.
|
||||
///
|
||||
/// The measure of how much of the library is still drawing itself the slow way,
|
||||
/// and what a "re-index to fill these in" prompt would be counting.
|
||||
pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND crop IS NULL",
|
||||
[model_id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.map(|n| n as u64)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// The user does not want to identify this person.
|
||||
///
|
||||
/// Reversible, and deliberately not a deletion: deleting the group would only
|
||||
/// have it rebuilt by the next clustering pass, since the faces are still there
|
||||
/// and still similar to each other. See the V10 migration note.
|
||||
pub fn set_ignored(conn: &Connection, person: PersonId, ignored: bool) -> Result<(), CatalogError> {
|
||||
conn.execute(
|
||||
"UPDATE people
|
||||
SET ignored = ?2, revision = revision + 1, modified = ?3
|
||||
WHERE id = ?1",
|
||||
rusqlite::params![person.0 as i64, ignored, now_secs()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a person has been set aside.
|
||||
pub fn is_ignored(conn: &Connection, person: PersonId) -> Result<bool, CatalogError> {
|
||||
conn.query_row(
|
||||
"SELECT ignored FROM people WHERE id = ?1",
|
||||
[person.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|v| v.unwrap_or(false))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Delete unnamed people who hold no faces at all.
|
||||
///
|
||||
/// Clustering creates a person per unanchored group, and a later pass — a new
|
||||
/// threshold, more faces indexed, a split undone — can leave the previous run's
|
||||
/// group with nothing in it. Those are not tombstones and nothing refers to
|
||||
/// them; left alone they accumulate one rail entry per Regroup, which is what
|
||||
/// made pressing the button twice look like it had broken the screen.
|
||||
///
|
||||
/// **Named people are never touched, however empty.** A name is user data
|
||||
/// (FR-CULL-12), and a person the user named and then emptied by moving every
|
||||
/// face elsewhere is still a person they told us about. Nor is a person that
|
||||
/// something was merged into, which has to outlive its faces to keep
|
||||
/// redirecting.
|
||||
///
|
||||
/// Returns how many were removed.
|
||||
pub fn prune_empty_unnamed(conn: &Connection) -> Result<usize, CatalogError> {
|
||||
let n = conn.execute(
|
||||
"DELETE FROM people
|
||||
WHERE name = ''
|
||||
AND merged_into IS NULL
|
||||
AND ignored = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM face_person fp WHERE fp.person_id = people.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM people o WHERE o.merged_into = people.id)",
|
||||
[],
|
||||
)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
@@ -869,16 +1002,17 @@ mod tests {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_reaches_version_nine() {
|
||||
fn migration_reaches_the_current_version() {
|
||||
let c = db();
|
||||
let v: i64 = c
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 9);
|
||||
assert_eq!(v, crate::schema::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1282,4 +1416,168 @@ mod tests {
|
||||
assert_eq!(e[0].2[0], 7);
|
||||
assert!((e[0].3 - 180.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
// ── stored crops ──────────────────────────────────────────────────────
|
||||
|
||||
fn face_with_crop(seed: u8, crop: Vec<u8>) -> DetectedFace {
|
||||
DetectedFace { crop, ..face(seed) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_stored_with_a_face_comes_back_as_stored() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
let ids = record_detections(
|
||||
&c,
|
||||
img,
|
||||
"w600k_mbf",
|
||||
1024,
|
||||
&[face_with_crop(3, vec![9; 128])],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![9; 128]));
|
||||
}
|
||||
|
||||
/// A face indexed before crops existed has none, and that has to read back
|
||||
/// as an honest absence rather than an empty image.
|
||||
#[test]
|
||||
fn a_face_with_no_crop_reads_back_as_none() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||||
assert_eq!(crop(&c, ids[0]).unwrap(), None);
|
||||
assert_eq!(faces_without_crop(&c, "w600k_mbf").unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crops_come_back_per_person_and_skip_the_ones_without() {
|
||||
let c = db();
|
||||
let a = image(&c, 1);
|
||||
let b = image(&c, 2);
|
||||
let with = record_detections(&c, a, "w600k_mbf", 1024, &[face_with_crop(1, vec![4; 16])])
|
||||
.unwrap()[0];
|
||||
let without = record_detections(&c, b, "w600k_mbf", 1024, &[face(2)]).unwrap()[0];
|
||||
|
||||
let p = create_person(&c, "Anna").unwrap();
|
||||
confirm(&c, with, p).unwrap();
|
||||
confirm(&c, without, p).unwrap();
|
||||
|
||||
let crops = crops_for_person(&c, p, true).unwrap();
|
||||
assert_eq!(crops.len(), 1, "a face with no crop should not appear");
|
||||
assert_eq!(crops.get(&with), Some(&vec![4; 16]));
|
||||
}
|
||||
|
||||
/// Re-indexing replaces the crop along with everything else, so a better
|
||||
/// pass over the same photograph updates what the screen draws.
|
||||
#[test]
|
||||
fn re_indexing_replaces_the_stored_crop() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![1; 8])]).unwrap();
|
||||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![2; 8])])
|
||||
.unwrap();
|
||||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![2; 8]));
|
||||
}
|
||||
|
||||
// ── setting a person aside ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_person_can_be_ignored_and_un_ignored() {
|
||||
let c = db();
|
||||
let p = create_person(&c, "").unwrap();
|
||||
assert!(!is_ignored(&c, p).unwrap());
|
||||
|
||||
set_ignored(&c, p, true).unwrap();
|
||||
assert!(is_ignored(&c, p).unwrap());
|
||||
assert!(
|
||||
people(&c)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|q| q.id == p)
|
||||
.unwrap()
|
||||
.ignored
|
||||
);
|
||||
|
||||
set_ignored(&c, p, false).unwrap();
|
||||
assert!(!is_ignored(&c, p).unwrap());
|
||||
}
|
||||
|
||||
/// Ignoring is a judgement, so it bumps the revision the same way a rename
|
||||
/// does — a device that syncs has to see that something changed.
|
||||
#[test]
|
||||
fn ignoring_a_person_bumps_their_revision() {
|
||||
let c = db();
|
||||
let p = create_person(&c, "").unwrap();
|
||||
let before: i64 = c
|
||||
.query_row(
|
||||
"SELECT revision FROM people WHERE id = ?1",
|
||||
[p.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
set_ignored(&c, p, true).unwrap();
|
||||
let after: i64 = c
|
||||
.query_row(
|
||||
"SELECT revision FROM people WHERE id = ?1",
|
||||
[p.0 as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
// ── pruning what clustering left behind ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_empty_unnamed_person_is_pruned() {
|
||||
let c = db();
|
||||
create_person(&c, "").unwrap();
|
||||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 1);
|
||||
assert!(people(&c).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// The rule that matters: a name is user data and survives whatever else
|
||||
/// happens to the group.
|
||||
#[test]
|
||||
fn an_empty_named_person_is_kept() {
|
||||
let c = db();
|
||||
create_person(&c, "Anna").unwrap();
|
||||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||||
assert_eq!(people(&c).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_person_with_faces_is_kept() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
let f = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap()[0];
|
||||
let p = create_person(&c, "").unwrap();
|
||||
suggest(&c, f, p, 0.95).unwrap();
|
||||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||||
}
|
||||
|
||||
/// An ignored group is empty of nothing — the user ruled on it, and
|
||||
/// deleting it would bring it straight back on the next Regroup.
|
||||
#[test]
|
||||
fn an_ignored_person_is_never_pruned() {
|
||||
let c = db();
|
||||
let p = create_person(&c, "").unwrap();
|
||||
set_ignored(&c, p, true).unwrap();
|
||||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||||
}
|
||||
|
||||
/// A merge tombstone has to outlive its faces or the device on the other
|
||||
/// side of the sync resurrects the person it redirects.
|
||||
#[test]
|
||||
fn a_merge_target_is_never_pruned() {
|
||||
let c = db();
|
||||
let target = create_person(&c, "").unwrap();
|
||||
let source = create_person(&c, "").unwrap();
|
||||
c.execute(
|
||||
"UPDATE people SET merged_into = ?2 WHERE id = ?1",
|
||||
rusqlite::params![source.0 as i64, target.0 as i64],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 9;
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -91,6 +91,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 10 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(V10)?;
|
||||
tx.pragma_update(None, "user_version", 10)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
@@ -339,6 +346,50 @@ fn stem_of(path: &str) -> &str {
|
||||
/// Both narrow the walk rather than reorder it, so the index still supplies the
|
||||
/// ordering and SQLite tests the extra predicate per row. That is the cheap
|
||||
/// direction: the expensive part was never the filtering, it was the sort.
|
||||
const V10: &str = r#"
|
||||
-- TRACES: FR-CULL-10 | FR-CULL-12
|
||||
-- Two columns the People screen turned out to need, and neither is derivable.
|
||||
|
||||
-- A person the user does not want to identify.
|
||||
--
|
||||
-- Most of a real library's clusters are strangers: people in the background of
|
||||
-- a street, guests at somebody else's party, a face on a poster. They are
|
||||
-- correctly detected and correctly grouped, and the user will never name any of
|
||||
-- them -- but they crowd out the handful of groups that matter, and there is no
|
||||
-- way to tell "not yet looked at" from "looked at, don't care" without
|
||||
-- recording the second.
|
||||
--
|
||||
-- **User data**, and the reason this is a column rather than a deletion: a
|
||||
-- deleted cluster comes straight back on the next Regroup, because the faces
|
||||
-- are still there and still similar. Nothing short of remembering the judgement
|
||||
-- survives re-clustering, which is the same argument `face_person_rejected`
|
||||
-- makes one level down (FR-CULL-12).
|
||||
ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- The face, cut out and kept.
|
||||
--
|
||||
-- A face used to be drawn by decoding the 1024px proxy it was found on and
|
||||
-- cutting the box out again, every time the screen opened. That made the People
|
||||
-- screen a *derivative of the thumbnail cache*: evict a proxy -- which the
|
||||
-- cache is entitled to do at any moment -- and the cell goes blank, with no way
|
||||
-- back short of re-fetching the original over the network and re-detecting it.
|
||||
-- It also cost one full JPEG decode per image per visit to show a 96px cell.
|
||||
--
|
||||
-- So the crop is cut once, when the pixels are already in hand at detection
|
||||
-- time, and kept. Small: a 160px JPEG is a few KB, against ~250 KB for the
|
||||
-- proxy it replaces reading.
|
||||
--
|
||||
-- Nullable, because a face indexed before this column existed has no crop and
|
||||
-- must still work -- the reader falls back to the old proxy path, and the next
|
||||
-- indexing pass fills it in.
|
||||
--
|
||||
-- **Stripped from the sync snapshot.** The catalog is uploaded whole, so this
|
||||
-- would otherwise put tens of MB of JPEG on every sync; crops travel in the
|
||||
-- face shards instead, which is where the bulk per-face data already goes
|
||||
-- (`face_shard`). See `sync::snapshot_for_upload`.
|
||||
ALTER TABLE faces ADD COLUMN crop BLOB;
|
||||
"#;
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
|
||||
@@ -61,6 +61,42 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog
|
||||
// have. The effect is the same: one step, no interleaved writers, no
|
||||
// progress callback. A 50k-image catalog is tens of megabytes.
|
||||
backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?;
|
||||
drop(backup);
|
||||
|
||||
strip_face_crops(&out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the stored face crops from a snapshot before it is uploaded.
|
||||
///
|
||||
/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded
|
||||
/// by every device. Face crops are a few KB each and a fully indexed library
|
||||
/// holds tens of thousands of them, so leaving them in would put tens of MB on
|
||||
/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound,
|
||||
/// and the reason the bulk per-face data lives in shards in the first place.
|
||||
///
|
||||
/// Crops are not lost by this: they travel in the face shards
|
||||
/// ([`crate::face_shard::export_to_shards`]), which are written once and
|
||||
/// downloaded once. Nothing reads a crop out of a merged remote catalog —
|
||||
/// [`merge_all`] touches collections and keywords only — so removing them here
|
||||
/// costs a receiving device nothing it would otherwise have had.
|
||||
///
|
||||
/// `VACUUM` afterwards because SQLite does not return freed pages to the file
|
||||
/// on its own, and an upload sized by the file rather than by its contents
|
||||
/// would keep paying for bytes that are no longer there.
|
||||
fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> {
|
||||
// A catalog older than the crop column is a legitimate input here — a
|
||||
// snapshot taken mid-migration, or a test fixture built from an earlier
|
||||
// schema — so an absent column is nothing to fail over.
|
||||
let has_crop = snapshot
|
||||
.prepare("SELECT crop FROM faces LIMIT 1")
|
||||
.map(|_| true)
|
||||
.unwrap_or(false);
|
||||
if !has_crop {
|
||||
return Ok(());
|
||||
}
|
||||
snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?;
|
||||
snapshot.execute_batch("VACUUM")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -250,4 +286,62 @@ mod tests {
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// The whole reason crops live in the shards: a snapshot is uploaded whole,
|
||||
/// on every sync, to every device.
|
||||
#[test]
|
||||
fn the_snapshot_carries_no_face_crops() {
|
||||
let dir = tempdir();
|
||||
let live = dir.join("catalog.sqlite");
|
||||
let snap = dir.join("snap.sqlite");
|
||||
|
||||
let c = seeded(&live);
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at, crop)
|
||||
VALUES (1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, 'm', 0, ?1)",
|
||||
[vec![7u8; 4096]],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
snapshot_for_upload(&c, &snap).unwrap();
|
||||
|
||||
let out = Connection::open(&snap).unwrap();
|
||||
let crops: i64 = out
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(crops, 0, "the snapshot still carries face crops");
|
||||
|
||||
// The face itself must still be there — only the pixels are dropped.
|
||||
let faces: i64 = out
|
||||
.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(faces, 1);
|
||||
|
||||
// And the local catalog keeps its crop: this strips the copy, never
|
||||
// the original.
|
||||
let kept: i64 = c
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user