Skip to main content

videre_api/
faces.rs

1//! Facade over videre's faces-labeling read operations. Plain functions over
2//! an open `rusqlite::Connection`, returning serde types and a shared
3//! `Error`. Called by the axum `--faces` server and any other embedder.
4
5use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::HashMap;
9
10/// People / unassigned clusters / singletons for the labeling page.
11pub fn faces_list(conn: &Connection) -> Result<FacesData> {
12    let mut people: HashMap<String, PersonData> = HashMap::new();
13    {
14        let mut stmt = conn.prepare(
15            "SELECT id, hash, person_label FROM faces \
16             WHERE confirmed = 1 AND person_label IS NOT NULL \
17             ORDER BY person_label, is_primary DESC, id ASC",
18        )?;
19        let rows = stmt.query_map([], |r| {
20            Ok((
21                r.get::<_, i64>(0)?,
22                r.get::<_, String>(1)?,
23                r.get::<_, String>(2)?,
24            ))
25        })?;
26        for row in rows {
27            let (id, hash, label) = row?;
28            let person = people.entry(label.clone()).or_insert(PersonData {
29                label: label.clone(),
30                face_ids: vec![],
31                representative_id: id,
32                hashes: vec![],
33            });
34            person.face_ids.push(id);
35            if !person.hashes.contains(&hash) {
36                person.hashes.push(hash);
37            }
38        }
39    }
40
41    let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
42    {
43        let mut stmt = conn.prepare(
44            "SELECT id, hash, cluster_id FROM faces \
45             WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
46             ORDER BY cluster_id, id",
47        )?;
48        let rows = stmt.query_map([], |r| {
49            Ok((
50                r.get::<_, i64>(0)?,
51                r.get::<_, String>(1)?,
52                r.get::<_, i64>(2)?,
53            ))
54        })?;
55        for row in rows {
56            let (id, hash, cid) = row?;
57            let cluster = cluster_map.entry(cid).or_insert(ClusterData {
58                cluster_id: cid,
59                face_ids: vec![],
60                hashes: vec![],
61            });
62            cluster.face_ids.push(id);
63            if !cluster.hashes.contains(&hash) {
64                cluster.hashes.push(hash);
65            }
66        }
67    }
68
69    let mut singletons: Vec<SingletonData> = vec![];
70    {
71        let mut stmt = conn.prepare(
72            "SELECT id, hash FROM faces \
73             WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
74             ORDER BY id",
75        )?;
76        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
77        for row in rows {
78            let (id, hash) = row?;
79            singletons.push(SingletonData { face_id: id, hash });
80        }
81    }
82
83    // Both maps are HashMaps, whose iteration order is arbitrary and differs
84    // between instances, so collecting straight from them threw away the
85    // ORDER BY the queries above establish. The labeling UI re-fetches this
86    // list after every assignment, so the effect was that people and clusters
87    // reshuffled on each drop: the cluster lined up next moved somewhere else,
88    // and so did the person being dragged onto. `singletons` never had the
89    // problem, and the difference is exactly that it is built as a Vec.
90    //
91    // Clusters are ordered largest first, which is the order people label in:
92    // the big clusters are worth the most and are the easiest to recognise.
93    // cluster_id breaks ties so the order is total, not merely sorted.
94    let mut people: Vec<PersonData> = people.into_values().collect();
95    people.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
96    let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
97    clusters.sort_by(|a, b| {
98        b.face_ids
99            .len()
100            .cmp(&a.face_ids.len())
101            .then(a.cluster_id.cmp(&b.cluster_id))
102    });
103
104    Ok(FacesData {
105        people,
106        clusters,
107        singletons,
108    })
109}
110
111/// Every face in one unassigned cluster (for the cluster detail page).
112pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
113    let mut stmt = conn.prepare(
114        "SELECT f.id, f.hash, fh.path FROM faces f \
115         JOIN file_hashes fh ON f.hash = fh.hash \
116         WHERE f.cluster_id = ?1 ORDER BY f.id",
117    )?;
118    let faces = stmt
119        .query_map([cluster_id], |r| {
120            Ok(ClusterFaceData {
121                face_id: r.get(0)?,
122                hash: r.get(1)?,
123                path: r.get(2)?,
124            })
125        })?
126        .collect::<rusqlite::Result<Vec<_>>>()?;
127    Ok(ClusterDetail { cluster_id, faces })
128}
129
130/// Every confirmed face for one person, primary first and flagged.
131pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
132    let mut stmt = conn.prepare(
133        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
134         JOIN file_hashes fh ON f.hash = fh.hash \
135         WHERE f.person_label = ?1 AND f.confirmed = 1 \
136         ORDER BY f.is_primary DESC, f.id",
137    )?;
138    let faces = stmt
139        .query_map([name], |r| {
140            Ok(PersonFaceData {
141                face_id: r.get(0)?,
142                hash: r.get(1)?,
143                path: r.get(2)?,
144                is_primary: r.get::<_, i64>(3)? != 0,
145            })
146        })?
147        .collect::<rusqlite::Result<Vec<_>>>()?;
148    Ok(PersonDetail {
149        label: name.to_string(),
150        faces,
151    })
152}
153
154/// Image paths for confirmed faces of a person (prefix match), for the
155/// person-name autocomplete. Delegates to the existing core search.
156pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
157    Ok(videre_core::person_search::search_by_person(
158        conn, name, None,
159    )?)
160}
161
162/// Assign faces to an existing/new person: sets person_label + confirmed.
163/// Rejects an empty label after sanitizing.
164pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
165    let label = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
166    for id in face_ids {
167        conn.execute(
168            "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
169            rusqlite::params![label, id],
170        )?;
171    }
172    Ok(())
173}
174
175/// Create a person from faces. Same effect as `assign`; kept as a distinct
176/// operation because callers treat "new person" and "assign to existing" as
177/// separate user intents.
178pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
179    assign(conn, face_ids, label)
180}
181
182/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
183pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
184    conn.execute(
185        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
186        [face_id],
187    )?;
188    Ok(())
189}
190
191/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
192pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
193    conn.execute(
194        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
195        [cluster_id],
196    )?;
197    Ok(())
198}
199
200/// Reset every face of a person back to unassigned. Deliberately does NOT touch
201/// cluster_id, so a face rejoins its cluster's unassigned group rather than
202/// scattering to singletons.
203pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
204    conn.execute(
205        "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
206        rusqlite::params![label],
207    )?;
208    Ok(())
209}
210
211/// Mark one face as the person's primary (their labeling-page thumbnail),
212/// clearing any previous primary in the same transaction so exactly one
213/// remains. The target update is guarded by person_label so it can't steal a
214/// face from another person.
215pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
216    conn.execute_batch("BEGIN")?;
217    let result = (|| -> rusqlite::Result<()> {
218        conn.execute(
219            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
220            rusqlite::params![person_label],
221        )?;
222        conn.execute(
223            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
224            rusqlite::params![person_label, face_id],
225        )?;
226        Ok(())
227    })();
228    match result {
229        Ok(()) => {
230            conn.execute_batch("COMMIT")?;
231            Ok(())
232        }
233        Err(e) => {
234            let _ = conn.execute_batch("ROLLBACK");
235            Err(Error::Db(e))
236        }
237    }
238}
239
240/// Rename a person. `NotFound` if `old_label` has no faces; `Conflict` if
241/// `new_label` (after sanitizing) already belongs to a different person;
242/// `Invalid` if the new label sanitizes to empty.
243pub fn rename_person(conn: &Connection, old_label: &str, new_label: &str) -> Result<()> {
244    let sanitized = crate::label::sanitize_person_label(new_label).ok_or(Error::Invalid)?;
245
246    let old_count: i64 = conn
247        .query_row(
248            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
249            rusqlite::params![old_label],
250            |row| row.get(0),
251        )
252        .unwrap_or(0);
253    if old_count == 0 {
254        return Err(Error::NotFound);
255    }
256
257    let collision_count: i64 = conn
258        .query_row(
259            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
260            rusqlite::params![sanitized],
261            |row| row.get(0),
262        )
263        .unwrap_or(0);
264    if collision_count > 0 && sanitized != old_label {
265        return Err(Error::Conflict);
266    }
267
268    conn.execute(
269        "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
270        rusqlite::params![sanitized, old_label],
271    )?;
272    Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    /// In-memory db with the faces + file_hashes tables and a few rows:
280    /// - face 1: person "Alice", confirmed, is_primary
281    /// - face 2: person "Alice", confirmed
282    /// - face 3: cluster 7 (unassigned)
283    /// - face 4: cluster 7 (unassigned)
284    /// - face 5: singleton (no cluster, unassigned)
285    pub(super) fn seed() -> Connection {
286        let conn = Connection::open_in_memory().unwrap();
287        videre_core::face_db::create_faces_table(&conn).unwrap();
288        conn.execute_batch(
289            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
290             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
291                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
292             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
293                (1,'h1','0,0,9,9',X'0000',NULL,'Alice',1,1),
294                (2,'h2','0,0,9,9',X'0000',NULL,'Alice',1,0),
295                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
296                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
297                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
298        )
299        .unwrap();
300        videre_core::db::ensure_file_hashes_columns(&conn);
301        conn
302    }
303
304    #[test]
305    fn the_list_comes_back_in_the_same_order_every_time() {
306        // The labeling UI re-fetches after every assignment, so an unstable
307        // order means the cluster lined up next moves, and so does the person
308        // being dragged onto. Both lists were collected straight out of a
309        // HashMap, which discarded the ORDER BY in the queries above.
310        // `singletons` never had the bug, and the only difference is that it is
311        // built as a Vec.
312        let conn = seed();
313        // The seed has one cluster and one person, which cannot show an
314        // ordering problem. Add enough of both to have an order at all, with
315        // sizes deliberately not matching id order.
316        conn.execute_batch(
317            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
318            // so the table has more than the two it was created with.
319            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
320                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
321             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
322                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
323                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
324                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
325                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
326                (10,'h10','0,0,9,9',X'0000',NULL,'Bob',1,0);",
327        )
328        .unwrap();
329
330        // Two calls on one connection: each builds fresh HashMaps, and Rust
331        // seeds them differently, so an unstable order shows up here.
332        let a = faces_list(&conn).unwrap();
333        let b = faces_list(&conn).unwrap();
334
335        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
336        let names =
337            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
338        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
339        assert_eq!(
340            ids(&a),
341            ids(&b),
342            "cluster order must not change between calls"
343        );
344        assert_eq!(
345            names(&a),
346            names(&b),
347            "people order must not change between calls"
348        );
349
350        // And the order is the useful one: biggest first, so the cluster worth
351        // the most labelling effort is where it is expected.
352        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
353        let mut want = sizes.clone();
354        want.sort_unstable_by(|x, y| y.cmp(x));
355        assert_eq!(
356            sizes, want,
357            "clusters must be ordered largest first, got {sizes:?}"
358        );
359    }
360
361    #[test]
362    fn faces_list_splits_people_clusters_singletons() {
363        let conn = seed();
364        let d = faces_list(&conn).unwrap();
365        assert_eq!(d.people.len(), 1);
366        assert_eq!(d.people[0].label, "Alice");
367        assert_eq!(
368            d.people[0].representative_id, 1,
369            "primary face is representative"
370        );
371        assert_eq!(d.clusters.len(), 1);
372        assert_eq!(d.clusters[0].cluster_id, 7);
373        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
374        assert_eq!(d.singletons.len(), 1);
375        assert_eq!(d.singletons[0].face_id, 5);
376    }
377
378    #[test]
379    fn person_detail_marks_primary() {
380        let conn = seed();
381        let p = person_detail(&conn, "Alice").unwrap();
382        assert_eq!(p.faces.len(), 2);
383        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
384        assert!(!p.faces[1].is_primary);
385    }
386
387    #[test]
388    fn cluster_detail_lists_faces() {
389        let conn = seed();
390        let c = cluster_detail(&conn, 7).unwrap();
391        assert_eq!(c.cluster_id, 7);
392        assert_eq!(
393            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
394            vec![3, 4]
395        );
396    }
397
398    #[test]
399    fn assign_labels_and_confirms() {
400        let conn = seed();
401        assign(&conn, &[3, 4], "Bob").unwrap();
402        let p = person_detail(&conn, "Bob").unwrap();
403        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
404    }
405
406    #[test]
407    fn assign_rejects_empty_label() {
408        let conn = seed();
409        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
410    }
411
412    #[test]
413    fn remove_face_unassigns_everything() {
414        let conn = seed();
415        remove_face(&conn, 1).unwrap();
416        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
417            .query_row(
418                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
419                [],
420                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
421            )
422            .unwrap();
423        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
424    }
425
426    #[test]
427    fn dissolve_cluster_nulls_cluster_id() {
428        let conn = seed();
429        dissolve_cluster(&conn, 7).unwrap();
430        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
431        assert_eq!(
432            faces_list(&conn).unwrap().singletons.len(),
433            3,
434            "3,4 join 5 as singletons"
435        );
436    }
437
438    #[test]
439    fn delete_person_unassigns_without_touching_cluster() {
440        let conn = seed();
441        // Give one of Alice's faces a cluster_id so we can prove delete_person
442        // leaves cluster_id intact (it must, so the face rejoins its cluster's
443        // unassigned group rather than scattering to singletons).
444        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
445            .unwrap();
446        delete_person(&conn, "Alice").unwrap();
447        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
448        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
449            .query_row(
450                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
451                [],
452                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
453            )
454            .unwrap();
455        assert_eq!(cid, Some(42), "cluster_id must be preserved");
456        assert_eq!(label, None, "person_label cleared");
457        assert_eq!(confirmed, 0, "confirmed cleared");
458    }
459
460    #[test]
461    fn set_primary_is_exclusive_per_person() {
462        let conn = seed();
463        set_primary(&conn, 2, "Alice").unwrap();
464        let primaries: Vec<i64> = {
465            let mut s = conn
466                .prepare("SELECT id FROM faces WHERE person_label='Alice' AND is_primary=1")
467                .unwrap();
468            s.query_map([], |r| r.get(0))
469                .unwrap()
470                .collect::<rusqlite::Result<_>>()
471                .unwrap()
472        };
473        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
474    }
475
476    #[test]
477    fn rename_missing_person_is_not_found() {
478        let conn = seed();
479        assert!(matches!(
480            rename_person(&conn, "Nobody", "X"),
481            Err(Error::NotFound)
482        ));
483    }
484
485    #[test]
486    fn rename_onto_existing_person_conflicts() {
487        let conn = seed();
488        assign(&conn, &[3], "Bob").unwrap(); // Bob now exists
489        assert!(matches!(
490            rename_person(&conn, "Alice", "Bob"),
491            Err(Error::Conflict)
492        ));
493    }
494
495    #[test]
496    fn rename_succeeds() {
497        let conn = seed();
498        rename_person(&conn, "Alice", "Alicia").unwrap();
499        assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
500        assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
501    }
502
503    #[test]
504    fn rename_to_empty_label_is_invalid() {
505        let conn = seed();
506        assert!(matches!(
507            rename_person(&conn, "Alice", "   "),
508            Err(Error::Invalid)
509        ));
510    }
511}