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:
2026-08-27 21:17:44 +02:00
co-authored by Claude Opus 5
parent 7275c020d7
commit 79c0520506
4 changed files with 489 additions and 13 deletions
+40 -7
View File
@@ -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],
}
}
+303 -5
View File
@@ -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);
}
}
+52 -1
View File
@@ -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
+94
View File
@@ -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");
}
}