Skip to main content

videre_core/
face_db.rs

1use half::f16;
2use rusqlite::Connection;
3use std::collections::HashMap;
4
5#[derive(Clone)]
6pub struct FaceRow {
7    pub hash: String,
8    pub bbox: String,
9    pub landmark: Option<String>,
10    pub embedding: Vec<u8>, // 512 f16 values as little-endian bytes (1024 bytes)
11    pub cluster_id: Option<i64>,
12    pub person_label: Option<String>,
13    pub confirmed: i64,
14    pub is_primary: i64,
15    /// SCRFD's confidence for this detection, and how sharp the aligned crop is
16    /// (variance of the Laplacian).
17    ///
18    /// :warning: **Both were computed and thrown away.** The detector has always
19    /// produced a score and the crop has always been available; nothing read
20    /// either, so the pipeline could not tell a confident, sharp face from a
21    /// doubtful, blurry one, and embedded both. Stored so quality can be judged
22    /// at detection time rather than guessed at cluster time.
23    pub det_score: f32,
24    pub blur: f32,
25    /// Which canvas the bbox/landmark coordinates are in: `false` = the raw
26    /// sensor canvas (every row written before the orientation fix, and
27    /// stored as NULL), `true` = the display canvas a person sees. Face
28    /// thumbnails branch on this when cropping. The pipeline writes `true`
29    /// since it decodes orientation-correctly; NULL must never read as
30    /// "display canvas".
31    pub oriented: bool,
32}
33
34/// Creates the `people` table if it is missing.
35///
36/// Called from `db::open_wal`, so it runs on **every** open rather than only
37/// when faces are written. That is the same reason `ensure_file_hashes_columns`
38/// is there: readers query this table - the labeling UI lists people, and
39/// `--person` resolves through it - and a library whose last `videre faces` run
40/// predates this table would otherwise fail with "no such table" on commands
41/// that never write faces.
42pub fn ensure_people_table(conn: &Connection) {
43    // One row per person: `name` is the identity form (see
44    // `videre_core::person::normalize`) and `full_name` is what a reader sees.
45    // `faces.person_label` holds the identity form and refers here.
46    //
47    // `name` is the primary key deliberately. It puts "two people cannot share
48    // an identity" in the database rather than in whichever code path remembers
49    // to check.
50    //
51    // No foreign key from `faces`: SQLite leaves `PRAGMA foreign_keys` off and
52    // videre never sets it, so a `REFERENCES` clause here would be
53    // documentation rather than a constraint, and code written to trust it
54    // would be wrong. Tracked separately.
55    let _ = conn.execute_batch(
56        "CREATE TABLE IF NOT EXISTS people (
57            name       TEXT PRIMARY KEY,
58            full_name  TEXT NOT NULL
59        );",
60    );
61}
62
63pub fn create_faces_table(conn: &Connection) -> rusqlite::Result<()> {
64    ensure_people_table(conn);
65    // Writers migrate; readers do not. `open_wal` only creates the empty table,
66    // because `stats` and `search` open databases they must not write to - a
67    // read-only mount or another process holding the writer lock would turn a
68    // report into a failure. This runs from the commands that already write
69    // faces, and is a single COUNT once the migration has happened.
70    let _ = migrate_person_labels(conn);
71    conn.execute_batch(
72        "CREATE TABLE IF NOT EXISTS faces (
73            id            INTEGER PRIMARY KEY,
74            hash          TEXT NOT NULL,
75            bbox          TEXT NOT NULL,
76            landmark      TEXT,
77            embedding     BLOB NOT NULL,
78            cluster_id    INTEGER,
79            person_label  TEXT,
80            confirmed     INTEGER DEFAULT 0,
81            is_primary    INTEGER DEFAULT 0,
82            det_score     REAL,
83            blur          REAL
84        );",
85    )?;
86    // Migration for existing tables without is_primary column; ignored if already exists.
87    let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN is_primary INTEGER DEFAULT 0");
88    // Same shape: existing libraries gain the columns as NULL, which reads as
89    // "not recorded" rather than "bad", so nothing is retro-excluded.
90    let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN det_score REAL");
91    let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN blur REAL");
92    // Canvas marker: NULL = detected on the raw sensor canvas (before the
93    // orientation fix), 1 = detected on the display canvas. Readers branch on
94    // this when cropping (face thumbnails); NULL must never read as
95    // "display canvas".
96    let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN oriented INTEGER");
97
98    // Records every hash whose faces have been scanned, INCLUDING images where
99    // zero faces were detected (which leave no `faces` row). This is what makes
100    // `videre faces` resumable: the skip set is "already scanned", not merely
101    // "has a face", so a no-face image is never re-detected on a later run.
102    conn.execute_batch(
103        "CREATE TABLE IF NOT EXISTS faces_scanned (
104            hash        TEXT PRIMARY KEY,
105            scanned_at  TEXT DEFAULT (datetime('now'))
106        );",
107    )?;
108    Ok(())
109}
110
111/// Marks a hash as face-scanned (idempotent). Call after detection runs for a
112/// hash regardless of whether any faces were found.
113pub fn mark_scanned(conn: &Connection, hash: &str) -> rusqlite::Result<()> {
114    conn.execute(
115        "INSERT OR IGNORE INTO faces_scanned (hash) VALUES (?1)",
116        rusqlite::params![hash],
117    )?;
118    Ok(())
119}
120
121/// Every hash recorded as face-scanned.
122pub fn scanned_hashes(conn: &Connection) -> rusqlite::Result<Vec<String>> {
123    let mut stmt = conn.prepare("SELECT hash FROM faces_scanned")?;
124    let rows = stmt.query_map([], |r| r.get(0))?;
125    rows.collect()
126}
127
128/// From `(path, hash)` pairs, drop hashes in `skip`, keep one representative
129/// path per remaining hash (first seen), preserving input order, and cap the
130/// result at `limit` distinct hashes (`None` = no cap). Used to build the work
131/// list for a resumable, optionally partial face-detection pass.
132pub fn select_unscanned(
133    all: &[(String, String)],
134    skip: &std::collections::HashSet<String>,
135    limit: Option<usize>,
136) -> Vec<(String, String)> {
137    let mut seen = std::collections::HashSet::new();
138    let mut out = Vec::new();
139    for (path, hash) in all {
140        if skip.contains(hash) || !seen.insert(hash.clone()) {
141            continue;
142        }
143        out.push((path.clone(), hash.clone()));
144        if let Some(n) = limit {
145            if out.len() >= n {
146                break;
147            }
148        }
149    }
150    out
151}
152
153pub fn replace_faces_for_hash(
154    conn: &Connection,
155    hash: &str,
156    faces: &[FaceRow],
157) -> rusqlite::Result<()> {
158    conn.execute_batch("BEGIN")?;
159    let result = (|| -> rusqlite::Result<()> {
160        conn.execute("DELETE FROM faces WHERE hash = ?1", rusqlite::params![hash])?;
161        for face in faces {
162            conn.execute(
163                "INSERT INTO faces (hash, bbox, landmark, embedding, cluster_id, person_label, confirmed, is_primary, det_score, blur, oriented)
164                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
165                rusqlite::params![
166                    face.hash, face.bbox, face.landmark, face.embedding,
167                    face.cluster_id, face.person_label, face.confirmed, face.is_primary,
168                    face.det_score, face.blur, if face.oriented { 1 } else { 0 }
169                ],
170            )?;
171        }
172        Ok(())
173    })();
174    match result {
175        Ok(()) => {
176            conn.execute_batch("COMMIT")?;
177            Ok(())
178        }
179        Err(e) => {
180            let _ = conn.execute_batch("ROLLBACK");
181            Err(e)
182        }
183    }
184}
185
186pub fn load_face_embeddings(conn: &Connection) -> rusqlite::Result<Vec<(i64, Vec<f32>)>> {
187    let mut stmt = conn.prepare("SELECT id, embedding FROM faces")?;
188    let rows = stmt.query_map([], |row| {
189        let id: i64 = row.get(0)?;
190        let blob: Vec<u8> = row.get(1)?;
191        Ok((id, blob))
192    })?;
193    let mut out = Vec::new();
194    for row in rows {
195        let (id, blob) = row?;
196        let emb: Vec<f32> = blob
197            .chunks_exact(2)
198            .map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32())
199            .collect();
200        out.push((id, emb));
201    }
202    Ok(out)
203}
204
205/// Like [`load_face_embeddings`] but also returns each face's smaller bbox
206/// side in pixels (the shorter of width/height), parsed from the `"x,y,w,h"`
207/// bbox string. Used as a quality signal: very small face crops embed into
208/// near-degenerate ArcFace vectors that cluster together regardless of
209/// identity, so callers gate them out of clustering. A bbox that fails to
210/// parse yields a min-side of 0.0 (treated as lowest quality).
211pub fn load_faces_for_clustering(
212    conn: &Connection,
213) -> rusqlite::Result<Vec<(i64, Vec<f32>, f32, Option<String>, Option<f32>)>> {
214    // The landmark string travels as-is. What "a good landmark set" means is a
215    // property of the ArcFace template, which lives in `videre-ml`; this crate
216    // is below it in the dependency graph and must not learn it.
217    let mut stmt = conn.prepare("SELECT id, embedding, bbox, landmark, blur FROM faces")?;
218    let rows = stmt.query_map([], |row| {
219        let id: i64 = row.get(0)?;
220        let blob: Vec<u8> = row.get(1)?;
221        let bbox: String = row.get(2)?;
222        let landmark: Option<String> = row.get(3)?;
223        let blur: Option<f32> = row.get(4)?;
224        Ok((id, blob, bbox, landmark, blur))
225    })?;
226    let mut out = Vec::new();
227    for row in rows {
228        let (id, blob, bbox, landmark, blur) = row?;
229        let emb: Vec<f32> = blob
230            .chunks_exact(2)
231            .map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32())
232            .collect();
233        out.push((id, emb, bbox_min_side(&bbox), landmark, blur));
234    }
235    Ok(out)
236}
237
238/// Smaller side (min of width, height) of a `"x,y,w,h"` bbox string, or 0.0 if
239/// it does not parse into at least four numeric fields.
240fn bbox_min_side(bbox: &str) -> f32 {
241    let nums: Vec<f32> = bbox
242        .split(',')
243        .filter_map(|s| s.trim().parse().ok())
244        .collect();
245    if nums.len() >= 4 {
246        nums[2].min(nums[3])
247    } else {
248        0.0
249    }
250}
251
252pub fn update_cluster_assignments(
253    conn: &Connection,
254    assignments: &[(i64, Option<i64>)],
255) -> rusqlite::Result<()> {
256    for (face_id, cluster_id) in assignments {
257        conn.execute(
258            "UPDATE faces SET cluster_id = ?1 WHERE id = ?2",
259            rusqlite::params![cluster_id, face_id],
260        )?;
261    }
262    Ok(())
263}
264
265pub fn hashes_with_faces(conn: &Connection) -> rusqlite::Result<Vec<String>> {
266    let mut stmt = conn.prepare("SELECT DISTINCT hash FROM faces ORDER BY hash")?;
267    let rows = stmt.query_map([], |r| r.get(0))?;
268    rows.collect()
269}
270
271/// (face_id, person_label, bbox, display_canvas) for one labeled face.
272///
273/// `display_canvas` mirrors `faces.oriented`: `false` means the bbox is in
274/// the raw sensor canvas of a row written before the orientation fix, `true`
275/// means display canvas. Only the server-side crop paths consult it; overlay
276/// JSON passes the coordinates through untouched.
277pub type LabeledFace = (i64, String, String, bool);
278
279/// Maps a file hash to every labeled face on it, as returned by
280/// `labeled_faces_by_hash`.
281pub type LabeledFacesByHash = HashMap<String, Vec<LabeledFace>>;
282
283/// Returns, for every hash that has at least one confirmed+labeled face, the
284/// list of (face_id, person_label, bbox, display_canvas) for that hash. One
285/// batched query covering every hash, not one query per file, safe to call
286/// once per report generation without N+1 overhead.
287pub fn labeled_faces_by_hash(conn: &Connection) -> rusqlite::Result<LabeledFacesByHash> {
288    let mut stmt = conn.prepare(
289        // The display name, not the identity: this feeds the face overlays in
290        // the gallery, which a person reads. LEFT JOIN so a label written
291        // before the people table existed still renders, as itself.
292        "SELECT f.hash, f.id, f.bbox, COALESCE(p.full_name, f.person_label), \
293         COALESCE(f.oriented, 0) \
294         FROM faces f LEFT JOIN people p ON p.name = f.person_label \
295         WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
296         ORDER BY f.hash, f.id",
297    )?;
298    let rows = stmt.query_map([], |r| {
299        Ok((
300            r.get::<_, String>(0)?,
301            r.get::<_, i64>(1)?,
302            r.get::<_, String>(2)?,
303            r.get::<_, String>(3)?,
304            r.get::<_, i64>(4)? != 0,
305        ))
306    })?;
307    let mut map: LabeledFacesByHash = HashMap::new();
308    for row in rows {
309        let (hash, id, bbox, label, oriented) = row?;
310        map.entry(hash)
311            .or_default()
312            .push((id, label, bbox, oriented));
313    }
314    Ok(map)
315}
316
317#[cfg(test)]
318fn make_embedding(vals: &[f32]) -> Vec<u8> {
319    vals.iter()
320        .flat_map(|&v| f16::from_f32(v).to_le_bytes())
321        .collect()
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn open() -> Connection {
329        let conn = Connection::open_in_memory().unwrap();
330        create_faces_table(&conn).unwrap();
331        conn
332    }
333
334    #[test]
335    fn create_table_idempotent() {
336        let conn = open();
337        create_faces_table(&conn).unwrap();
338    }
339
340    #[test]
341    fn replace_writes_the_oriented_flag() {
342        // NULL = legacy row detected on the raw sensor canvas; 1 = detected
343        // on the display canvas. The pipeline writes 1 since the orientation
344        // fix; nothing ever writes 0.
345        let conn = open();
346        let mut row = FaceRow {
347            hash: "habc".into(),
348            bbox: "0,0,50,50".into(),
349            landmark: None,
350            embedding: make_embedding(&vec![0.5f32; 512]),
351            cluster_id: None,
352            person_label: None,
353            confirmed: 0,
354            is_primary: 0,
355            det_score: 0.9,
356            blur: 1000.0,
357            oriented: true,
358        };
359        replace_faces_for_hash(&conn, "habc", &[row.clone()]).unwrap();
360        let oriented: i64 = conn
361            .query_row("SELECT oriented FROM faces WHERE hash = 'habc'", [], |r| {
362                r.get(0)
363            })
364            .unwrap();
365        assert_eq!(oriented, 1, "new detections are display-canvas");
366
367        row.oriented = false;
368        replace_faces_for_hash(&conn, "habc", &[row]).unwrap();
369        let oriented: Option<i64> = conn
370            .query_row("SELECT oriented FROM faces WHERE hash = 'habc'", [], |r| {
371                r.get(0)
372            })
373            .unwrap();
374        assert_eq!(oriented, Some(0), "false must not read back as NULL");
375    }
376
377    #[test]
378    fn insert_and_load_embedding() {
379        let conn = open();
380        let emb = make_embedding(&vec![0.5f32; 512]);
381        replace_faces_for_hash(
382            &conn,
383            "habc",
384            &[FaceRow {
385                hash: "habc".into(),
386                bbox: "0,0,50,50".into(),
387                landmark: None,
388                embedding: emb,
389                cluster_id: None,
390                person_label: None,
391                confirmed: 0,
392                is_primary: 0,
393                det_score: 0.9,
394                blur: 1000.0,
395                oriented: false,
396            }],
397        )
398        .unwrap();
399        let rows = load_face_embeddings(&conn).unwrap();
400        assert_eq!(rows.len(), 1);
401        let (id, emb_f32) = &rows[0];
402        assert!(*id > 0);
403        assert_eq!(emb_f32.len(), 512);
404        assert!((emb_f32[0] - 0.5).abs() < 0.01);
405    }
406
407    #[test]
408    fn replace_removes_old_rows_for_same_hash() {
409        let conn = open();
410        let emb = make_embedding(&vec![0.0f32; 512]);
411        replace_faces_for_hash(
412            &conn,
413            "h1",
414            &[
415                FaceRow {
416                    hash: "h1".into(),
417                    bbox: "0,0,10,10".into(),
418                    landmark: None,
419                    embedding: emb.clone(),
420                    cluster_id: None,
421                    person_label: None,
422                    confirmed: 0,
423                    is_primary: 0,
424                    det_score: 0.9,
425                    blur: 1000.0,
426                    oriented: false,
427                },
428                FaceRow {
429                    hash: "h1".into(),
430                    bbox: "20,0,10,10".into(),
431                    landmark: None,
432                    embedding: emb.clone(),
433                    cluster_id: None,
434                    person_label: None,
435                    confirmed: 0,
436                    is_primary: 0,
437                    det_score: 0.9,
438                    blur: 1000.0,
439                    oriented: false,
440                },
441            ],
442        )
443        .unwrap();
444        replace_faces_for_hash(
445            &conn,
446            "h1",
447            &[FaceRow {
448                hash: "h1".into(),
449                bbox: "99,0,10,10".into(),
450                landmark: None,
451                embedding: emb,
452                cluster_id: None,
453                person_label: None,
454                confirmed: 0,
455                is_primary: 0,
456                det_score: 0.9,
457                blur: 1000.0,
458                oriented: false,
459            }],
460        )
461        .unwrap();
462        let rows = load_face_embeddings(&conn).unwrap();
463        assert_eq!(rows.len(), 1);
464    }
465
466    #[test]
467    fn update_cluster_assignments_works() {
468        let conn = open();
469        let emb = make_embedding(&vec![0.0f32; 512]);
470        replace_faces_for_hash(
471            &conn,
472            "h1",
473            &[FaceRow {
474                hash: "h1".into(),
475                bbox: "0,0,10,10".into(),
476                landmark: None,
477                embedding: emb,
478                cluster_id: None,
479                person_label: None,
480                confirmed: 0,
481                is_primary: 0,
482                det_score: 0.9,
483                blur: 1000.0,
484                oriented: false,
485            }],
486        )
487        .unwrap();
488        let rows = load_face_embeddings(&conn).unwrap();
489        let id = rows[0].0;
490        update_cluster_assignments(&conn, &[(id, Some(3))]).unwrap();
491        let n: i64 = conn
492            .query_row("SELECT cluster_id FROM faces WHERE id=?1", [id], |r| {
493                r.get(0)
494            })
495            .unwrap();
496        assert_eq!(n, 3);
497    }
498
499    #[test]
500    fn load_faces_for_clustering_returns_bbox_min_side() {
501        let conn = open();
502        let emb = make_embedding(&vec![0.25f32; 512]);
503        // bbox "x,y,w,h": min side is min(w,h).
504        replace_faces_for_hash(
505            &conn,
506            "h1",
507            &[
508                FaceRow {
509                    hash: "h1".into(),
510                    bbox: "10,10,200,300".into(),
511                    landmark: None,
512                    embedding: emb.clone(),
513                    cluster_id: None,
514                    person_label: None,
515                    confirmed: 0,
516                    is_primary: 0,
517                    det_score: 0.9,
518                    blur: 1000.0,
519                    oriented: false,
520                },
521                FaceRow {
522                    hash: "h1".into(),
523                    bbox: "0,0,40,25".into(),
524                    landmark: None,
525                    embedding: emb,
526                    cluster_id: None,
527                    person_label: None,
528                    confirmed: 0,
529                    is_primary: 0,
530                    det_score: 0.9,
531                    blur: 1000.0,
532                    oriented: false,
533                },
534            ],
535        )
536        .unwrap();
537        let mut rows = load_faces_for_clustering(&conn).unwrap();
538        rows.sort_by(|a, b| b.2.total_cmp(&a.2));
539        assert_eq!(rows[0].2, 200.0, "min side of 200x300 bbox");
540        assert_eq!(rows[1].2, 25.0, "min side of 40x25 bbox");
541        assert_eq!(rows[0].1.len(), 512, "embedding still decoded");
542    }
543
544    #[test]
545    fn mark_scanned_records_hash_even_with_zero_faces() {
546        let conn = open();
547        // A hash processed with no detected faces leaves no `faces` row, but
548        // must still be recorded as scanned so it is not re-processed.
549        mark_scanned(&conn, "noface").unwrap();
550        assert_eq!(scanned_hashes(&conn).unwrap(), vec!["noface".to_string()]);
551        // hashes_with_faces stays empty, the marker is independent of faces.
552        assert!(hashes_with_faces(&conn).unwrap().is_empty());
553    }
554
555    #[test]
556    fn mark_scanned_is_idempotent() {
557        let conn = open();
558        mark_scanned(&conn, "h").unwrap();
559        mark_scanned(&conn, "h").unwrap();
560        assert_eq!(scanned_hashes(&conn).unwrap().len(), 1);
561    }
562
563    #[test]
564    fn select_unscanned_skips_dedups_and_limits() {
565        // Two paths share hash "a"; "b" is skipped; "c","d","e" remain.
566        let all = vec![
567            ("/1.jpg".to_string(), "a".to_string()),
568            ("/1copy.jpg".to_string(), "a".to_string()),
569            ("/2.jpg".to_string(), "b".to_string()),
570            ("/3.jpg".to_string(), "c".to_string()),
571            ("/4.jpg".to_string(), "d".to_string()),
572            ("/5.jpg".to_string(), "e".to_string()),
573        ];
574        let skip: std::collections::HashSet<String> = ["b".to_string()].into_iter().collect();
575        // No limit: one path per unscanned hash (a,c,d,e), b excluded.
576        let out = select_unscanned(&all, &skip, None);
577        assert_eq!(
578            out.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
579            vec!["a", "c", "d", "e"]
580        );
581        // Limit 2: first two unscanned hashes only.
582        let out2 = select_unscanned(&all, &skip, Some(2));
583        assert_eq!(
584            out2.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
585            vec!["a", "c"]
586        );
587    }
588
589    #[test]
590    fn hashes_with_faces_returns_inserted_hash() {
591        let conn = open();
592        let emb = make_embedding(&vec![0.0f32; 512]);
593        replace_faces_for_hash(
594            &conn,
595            "myhash",
596            &[FaceRow {
597                hash: "myhash".into(),
598                bbox: "0,0,10,10".into(),
599                landmark: None,
600                embedding: emb,
601                cluster_id: None,
602                person_label: None,
603                confirmed: 0,
604                is_primary: 0,
605                det_score: 0.9,
606                blur: 1000.0,
607                oriented: false,
608            }],
609        )
610        .unwrap();
611        let hashes = hashes_with_faces(&conn).unwrap();
612        assert_eq!(hashes, vec!["myhash"]);
613    }
614
615    #[test]
616    fn labeled_faces_by_hash_returns_only_confirmed_labeled() {
617        let conn = Connection::open_in_memory().unwrap();
618        create_faces_table(&conn).unwrap();
619        conn.execute_batch(
620            "INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
621             VALUES ('h1', '0,0,10,10', X'0000', 'Alice', 1); \
622             INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
623             VALUES ('h1', '20,20,10,10', X'0000', NULL, 0); \
624             INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
625             VALUES ('h2', '0,0,10,10', X'0000', 'Bob', 1);",
626        )
627        .unwrap();
628
629        let map = labeled_faces_by_hash(&conn).unwrap();
630        assert_eq!(map.len(), 2, "expected two hashes with labeled faces");
631        let h1 = &map["h1"];
632        assert_eq!(h1.len(), 1, "unconfirmed/unlabeled face must be excluded");
633        assert_eq!(h1[0].1, "Alice");
634        assert_eq!(map["h2"][0].1, "Bob");
635    }
636}
637
638/// One-off: give every existing `person_label` an identity and a display name.
639///
640/// Before this, a person was a single string on every face row, compared with
641/// `=`. `alice` and `Alice` were two people. Afterwards `faces.person_label`
642/// holds the identity form and `people` holds what to show.
643///
644/// Idempotent and guarded: it runs only when `people` is empty and labelled
645/// faces exist, so a second call does nothing. It is the one irreversible step
646/// in this change, so it reports what it did rather than working silently.
647///
648/// Returns `(people, merged)` - how many people exist afterwards, and how many
649/// labels collapsed into an existing one.
650pub fn migrate_person_labels(conn: &Connection) -> rusqlite::Result<(usize, usize)> {
651    ensure_people_table(conn);
652
653    let already: i64 = conn.query_row("SELECT COUNT(*) FROM people", [], |r| r.get(0))?;
654    if already > 0 {
655        return Ok((already as usize, 0));
656    }
657
658    // (label, face count), most-used first: when two labels collapse to one
659    // identity, the more-used spelling wins the display name. A tie falls to
660    // the one containing an uppercase letter, which is more likely to be the
661    // proper noun someone typed deliberately.
662    let mut labels: Vec<(String, i64)> = {
663        let mut stmt = conn.prepare(
664            "SELECT person_label, COUNT(*) FROM faces \
665             WHERE person_label IS NOT NULL AND person_label <> '' \
666             GROUP BY person_label",
667        )?;
668        let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
669        rows.collect::<rusqlite::Result<_>>()?
670    };
671    if labels.is_empty() {
672        return Ok((0, 0));
673    }
674    labels.sort_by(|a, b| {
675        b.1.cmp(&a.1)
676            .then(
677                b.0.chars()
678                    .any(|c| c.is_uppercase())
679                    .cmp(&a.0.chars().any(|c| c.is_uppercase())),
680            )
681            .then(a.0.cmp(&b.0))
682    });
683
684    let mut chosen: Vec<(String, String, String)> = Vec::new(); // (old label, name, full_name)
685    let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
686    let mut merged = 0usize;
687
688    for (label, _) in &labels {
689        let Some(name) = crate::person::normalize(label) else {
690            // Nothing usable: leave the label alone rather than inventing an
691            // identity, so a human can look at it.
692            continue;
693        };
694        let full = crate::person::display_name(label).unwrap_or_else(|| label.clone());
695        if seen.contains_key(&name) {
696            merged += 1;
697        } else {
698            seen.insert(name.clone(), full.clone());
699        }
700        chosen.push((label.clone(), name, full));
701    }
702
703    let tx = conn.unchecked_transaction()?;
704    for (name, full) in &seen {
705        tx.execute(
706            "INSERT INTO people (name, full_name) VALUES (?1, ?2) \
707             ON CONFLICT(name) DO NOTHING",
708            rusqlite::params![name, full],
709        )?;
710    }
711    for (old, name, _) in &chosen {
712        if old != name {
713            tx.execute(
714                "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
715                rusqlite::params![name, old],
716            )?;
717        }
718    }
719    tx.commit()?;
720
721    Ok((seen.len(), merged))
722}
723
724#[cfg(test)]
725mod migration_tests {
726    use super::*;
727
728    fn db() -> Connection {
729        let c = Connection::open_in_memory().unwrap();
730        create_faces_table(&c).unwrap();
731        c
732    }
733
734    fn label(c: &Connection, id: i64, label: &str) {
735        c.execute(
736            "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
737             VALUES (?1, ?2, '0,0,9,9', X'0000', ?3, 1)",
738            rusqlite::params![id, format!("h{id}"), label],
739        )
740        .unwrap();
741    }
742
743    #[test]
744    fn labels_become_identities_and_display_names() {
745        let c = db();
746        label(&c, 1, "Işıl Özyeğin");
747        label(&c, 2, "Erhan");
748        let (people, merged) = migrate_person_labels(&c).unwrap();
749        assert_eq!((people, merged), (2, 0));
750
751        let rows: Vec<(String, String)> = c
752            .prepare("SELECT name, full_name FROM people ORDER BY name")
753            .unwrap()
754            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
755            .unwrap()
756            .collect::<rusqlite::Result<_>>()
757            .unwrap();
758        assert_eq!(
759            rows,
760            vec![
761                ("erhan".to_string(), "Erhan".to_string()),
762                ("isil_ozyegin".to_string(), "Işıl Özyeğin".to_string()),
763            ]
764        );
765
766        let labels: Vec<String> = c
767            .prepare("SELECT person_label FROM faces ORDER BY id")
768            .unwrap()
769            .query_map([], |r| r.get(0))
770            .unwrap()
771            .collect::<rusqlite::Result<_>>()
772            .unwrap();
773        assert_eq!(labels, vec!["isil_ozyegin", "erhan"]);
774    }
775
776    #[test]
777    fn case_variants_merge_keeping_the_more_used_spelling() {
778        // The bug being fixed: these were two people. Afterwards they are one,
779        // displayed with the spelling that appeared on more faces.
780        let c = db();
781        label(&c, 1, "alice");
782        label(&c, 2, "Alice");
783        label(&c, 3, "Alice");
784        let (people, merged) = migrate_person_labels(&c).unwrap();
785        assert_eq!((people, merged), (1, 1));
786
787        let full: String = c
788            .query_row("SELECT full_name FROM people", [], |r| r.get(0))
789            .unwrap();
790        assert_eq!(full, "Alice", "the spelling on more faces wins");
791        let distinct: i64 = c
792            .query_row("SELECT COUNT(DISTINCT person_label) FROM faces", [], |r| {
793                r.get(0)
794            })
795            .unwrap();
796        assert_eq!(distinct, 1, "one identity now");
797    }
798
799    #[test]
800    fn running_it_twice_changes_nothing() {
801        let c = db();
802        label(&c, 1, "Erhan");
803        let first = migrate_person_labels(&c).unwrap();
804        let second = migrate_person_labels(&c).unwrap();
805        assert_eq!(first, (1, 0));
806        assert_eq!(second.0, 1, "second run is a no-op, not a re-migration");
807        let n: i64 = c
808            .query_row("SELECT COUNT(*) FROM people", [], |r| r.get(0))
809            .unwrap();
810        assert_eq!(n, 1);
811    }
812
813    #[test]
814    fn an_empty_library_is_not_an_error() {
815        let c = db();
816        assert_eq!(migrate_person_labels(&c).unwrap(), (0, 0));
817    }
818
819    #[test]
820    fn a_label_with_no_usable_identity_is_left_alone() {
821        // "!!!" normalizes to nothing. Inventing an identity would be worse
822        // than leaving it for a human to look at.
823        let c = db();
824        label(&c, 1, "!!!");
825        label(&c, 2, "Erhan");
826        let (people, _) = migrate_person_labels(&c).unwrap();
827        assert_eq!(people, 1);
828        let kept: String = c
829            .query_row("SELECT person_label FROM faces WHERE id = 1", [], |r| {
830                r.get(0)
831            })
832            .unwrap();
833        assert_eq!(kept, "!!!", "untouched rather than erased");
834    }
835}
836
837#[cfg(test)]
838mod people_table_tests {
839    use super::*;
840
841    #[test]
842    fn ensure_people_table_is_idempotent_and_keeps_rows() {
843        // It runs on every open, so a second call must not disturb what is
844        // there - the same property `ensure_file_hashes_columns` relies on.
845        let c = Connection::open_in_memory().unwrap();
846        ensure_people_table(&c);
847        c.execute(
848            "INSERT INTO people (name, full_name) VALUES ('erhan','Erhan')",
849            [],
850        )
851        .unwrap();
852        ensure_people_table(&c);
853        let n: i64 = c
854            .query_row("SELECT COUNT(*) FROM people", [], |r| r.get(0))
855            .unwrap();
856        assert_eq!(n, 1, "an existing row survives a second ensure");
857    }
858
859    #[test]
860    fn two_people_cannot_share_an_identity() {
861        // The primary key is what makes this a guarantee rather than something
862        // each write path has to remember to check.
863        let c = Connection::open_in_memory().unwrap();
864        ensure_people_table(&c);
865        c.execute(
866            "INSERT INTO people (name, full_name) VALUES ('erhan','Erhan')",
867            [],
868        )
869        .unwrap();
870        let second = c.execute(
871            "INSERT INTO people (name, full_name) VALUES ('erhan','Erhan Gündoğan')",
872            [],
873        );
874        assert!(second.is_err(), "the database refuses, not the caller");
875    }
876
877    #[test]
878    fn a_tie_on_face_count_prefers_the_capitalised_spelling() {
879        // Two spellings, one face each: `Alice` is likelier to be the name
880        // someone typed deliberately than `alice`.
881        let c = Connection::open_in_memory().unwrap();
882        create_faces_table(&c).unwrap();
883        for (id, label) in [(1, "alice"), (2, "Alice")] {
884            c.execute(
885                "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
886                 VALUES (?1, ?2, '0,0,9,9', X'0000', ?3, 1)",
887                rusqlite::params![id, format!("h{id}"), label],
888            )
889            .unwrap();
890        }
891        let (people, merged) = migrate_person_labels(&c).unwrap();
892        assert_eq!((people, merged), (1, 1));
893        let full: String = c
894            .query_row("SELECT full_name FROM people", [], |r| r.get(0))
895            .unwrap();
896        assert_eq!(full, "Alice");
897    }
898
899    #[test]
900    fn accented_and_unaccented_spellings_become_one_person() {
901        // `Şefik` and `Sefik` fold to the same identity. That is the intent -
902        // and the reason the display name is kept separately, so the accented
903        // spelling is not lost.
904        let c = Connection::open_in_memory().unwrap();
905        create_faces_table(&c).unwrap();
906        for (id, label) in [(1, "Şefik"), (2, "Şefik"), (3, "Sefik")] {
907            c.execute(
908                "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
909                 VALUES (?1, ?2, '0,0,9,9', X'0000', ?3, 1)",
910                rusqlite::params![id, format!("h{id}"), label],
911            )
912            .unwrap();
913        }
914        let (people, merged) = migrate_person_labels(&c).unwrap();
915        assert_eq!((people, merged), (1, 1));
916        let (name, full): (String, String) = c
917            .query_row("SELECT name, full_name FROM people", [], |r| {
918                Ok((r.get(0)?, r.get(1)?))
919            })
920            .unwrap();
921        assert_eq!(name, "sefik");
922        assert_eq!(full, "Şefik", "the accented spelling had more faces");
923    }
924
925    #[test]
926    fn every_migrated_face_points_at_a_person_row() {
927        // The invariant worth asserting on a real library: no face left holding
928        // a label that `people` does not know about.
929        let c = Connection::open_in_memory().unwrap();
930        create_faces_table(&c).unwrap();
931        for (id, label) in [(1, "Işıl Özyeğin"), (2, "Ahmet Arı"), (3, "erhan")] {
932            c.execute(
933                "INSERT INTO faces (id, hash, bbox, embedding, person_label, confirmed) \
934                 VALUES (?1, ?2, '0,0,9,9', X'0000', ?3, 1)",
935                rusqlite::params![id, format!("h{id}"), label],
936            )
937            .unwrap();
938        }
939        migrate_person_labels(&c).unwrap();
940        let orphans: i64 = c
941            .query_row(
942                "SELECT COUNT(*) FROM faces f LEFT JOIN people p ON p.name = f.person_label \
943                 WHERE f.person_label IS NOT NULL AND p.name IS NULL",
944                [],
945                |r| r.get(0),
946            )
947            .unwrap();
948        assert_eq!(orphans, 0);
949    }
950}
951
952#[cfg(test)]
953mod overlay_label_tests {
954    use super::*;
955
956    fn db() -> Connection {
957        let c = Connection::open_in_memory().unwrap();
958        create_faces_table(&c).unwrap();
959        c.execute_batch(
960            "INSERT INTO people (name, full_name) VALUES ('ozgur_demirtas','Özgür Demirtaş');
961             INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) VALUES
962               (1,'h1','10,10,60,60',X'0000','ozgur_demirtas',1),
963               (2,'h2','10,10,60,60',X'0000','no_row_yet',1),
964               (3,'h3','10,10,60,60',X'0000','ozgur_demirtas',0);",
965        )
966        .unwrap();
967        c
968    }
969
970    #[test]
971    fn face_overlays_show_the_display_name() {
972        // `report --show-faces` draws these on the photo, so they must read the
973        // way a person wrote them - `Özgür Demirtaş`, not `ozgur_demirtas`.
974        let m = labeled_faces_by_hash(&db()).unwrap();
975        let (_, name, _, _) = &m.get("h1").unwrap()[0];
976        assert_eq!(name, "Özgür Demirtaş");
977    }
978
979    #[test]
980    fn a_label_with_no_people_row_still_renders_as_itself() {
981        // Mid-migration, or written before the table existed: showing nothing
982        // would be worse than showing the raw label.
983        let m = labeled_faces_by_hash(&db()).unwrap();
984        let (_, name, _, _) = &m.get("h2").unwrap()[0];
985        assert_eq!(name, "no_row_yet");
986    }
987
988    #[test]
989    fn unconfirmed_faces_are_not_labelled_on_photos() {
990        // An unreviewed guess must not appear as a caption on someone's photo.
991        let m = labeled_faces_by_hash(&db()).unwrap();
992        assert!(!m.contains_key("h3"));
993    }
994}