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    Ok(FacesData {
84        people: people.into_values().collect(),
85        clusters: cluster_map.into_values().collect(),
86        singletons,
87    })
88}
89
90/// Every face in one unassigned cluster (for the cluster detail page).
91pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
92    let mut stmt = conn.prepare(
93        "SELECT f.id, f.hash, fh.path FROM faces f \
94         JOIN file_hashes fh ON f.hash = fh.hash \
95         WHERE f.cluster_id = ?1 ORDER BY f.id",
96    )?;
97    let faces = stmt
98        .query_map([cluster_id], |r| {
99            Ok(ClusterFaceData {
100                face_id: r.get(0)?,
101                hash: r.get(1)?,
102                path: r.get(2)?,
103            })
104        })?
105        .collect::<rusqlite::Result<Vec<_>>>()?;
106    Ok(ClusterDetail { cluster_id, faces })
107}
108
109/// Every confirmed face for one person, primary first and flagged.
110pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
111    let mut stmt = conn.prepare(
112        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
113         JOIN file_hashes fh ON f.hash = fh.hash \
114         WHERE f.person_label = ?1 AND f.confirmed = 1 \
115         ORDER BY f.is_primary DESC, f.id",
116    )?;
117    let faces = stmt
118        .query_map([name], |r| {
119            Ok(PersonFaceData {
120                face_id: r.get(0)?,
121                hash: r.get(1)?,
122                path: r.get(2)?,
123                is_primary: r.get::<_, i64>(3)? != 0,
124            })
125        })?
126        .collect::<rusqlite::Result<Vec<_>>>()?;
127    Ok(PersonDetail {
128        label: name.to_string(),
129        faces,
130    })
131}
132
133/// Image paths for confirmed faces of a person (prefix match), for the
134/// person-name autocomplete. Delegates to the existing core search.
135pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
136    Ok(videre_core::person_search::search_by_person(
137        conn, name, None,
138    )?)
139}
140
141/// Assign faces to an existing/new person: sets person_label + confirmed.
142/// Rejects an empty label after sanitizing.
143pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
144    let label = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
145    for id in face_ids {
146        conn.execute(
147            "UPDATE faces SET person_label = ?1, confirmed = 1 WHERE id = ?2",
148            rusqlite::params![label, id],
149        )?;
150    }
151    Ok(())
152}
153
154/// Create a person from faces. Same effect as `assign`; kept as a distinct
155/// operation because callers treat "new person" and "assign to existing" as
156/// separate user intents.
157pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
158    assign(conn, face_ids, label)
159}
160
161/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
162pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
163    conn.execute(
164        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
165        [face_id],
166    )?;
167    Ok(())
168}
169
170/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
171pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
172    conn.execute(
173        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
174        [cluster_id],
175    )?;
176    Ok(())
177}
178
179/// Reset every face of a person back to unassigned. Deliberately does NOT touch
180/// cluster_id, so a face rejoins its cluster's unassigned group rather than
181/// scattering to singletons.
182pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
183    conn.execute(
184        "UPDATE faces SET person_label = NULL, confirmed = 0, is_primary = 0 WHERE person_label = ?1",
185        rusqlite::params![label],
186    )?;
187    Ok(())
188}
189
190/// Mark one face as the person's primary (their labeling-page thumbnail),
191/// clearing any previous primary in the same transaction so exactly one
192/// remains. The target update is guarded by person_label so it can't steal a
193/// face from another person.
194pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
195    conn.execute_batch("BEGIN")?;
196    let result = (|| -> rusqlite::Result<()> {
197        conn.execute(
198            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
199            rusqlite::params![person_label],
200        )?;
201        conn.execute(
202            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
203            rusqlite::params![person_label, face_id],
204        )?;
205        Ok(())
206    })();
207    match result {
208        Ok(()) => {
209            conn.execute_batch("COMMIT")?;
210            Ok(())
211        }
212        Err(e) => {
213            let _ = conn.execute_batch("ROLLBACK");
214            Err(Error::Db(e))
215        }
216    }
217}
218
219/// Rename a person. `NotFound` if `old_label` has no faces; `Conflict` if
220/// `new_label` (after sanitizing) already belongs to a different person;
221/// `Invalid` if the new label sanitizes to empty.
222pub fn rename_person(conn: &Connection, old_label: &str, new_label: &str) -> Result<()> {
223    let sanitized = crate::label::sanitize_person_label(new_label).ok_or(Error::Invalid)?;
224
225    let old_count: i64 = conn
226        .query_row(
227            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
228            rusqlite::params![old_label],
229            |row| row.get(0),
230        )
231        .unwrap_or(0);
232    if old_count == 0 {
233        return Err(Error::NotFound);
234    }
235
236    let collision_count: i64 = conn
237        .query_row(
238            "SELECT COUNT(*) FROM faces WHERE person_label = ?1",
239            rusqlite::params![sanitized],
240            |row| row.get(0),
241        )
242        .unwrap_or(0);
243    if collision_count > 0 && sanitized != old_label {
244        return Err(Error::Conflict);
245    }
246
247    conn.execute(
248        "UPDATE faces SET person_label = ?1 WHERE person_label = ?2",
249        rusqlite::params![sanitized, old_label],
250    )?;
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// In-memory db with the faces + file_hashes tables and a few rows:
259    /// - face 1: person "Alice", confirmed, is_primary
260    /// - face 2: person "Alice", confirmed
261    /// - face 3: cluster 7 (unassigned)
262    /// - face 4: cluster 7 (unassigned)
263    /// - face 5: singleton (no cluster, unassigned)
264    pub(super) fn seed() -> Connection {
265        let conn = Connection::open_in_memory().unwrap();
266        videre_core::face_db::create_faces_table(&conn).unwrap();
267        conn.execute_batch(
268            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
269             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
270                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
271             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
272                (1,'h1','0,0,9,9',X'0000',NULL,'Alice',1,1),
273                (2,'h2','0,0,9,9',X'0000',NULL,'Alice',1,0),
274                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
275                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
276                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
277        )
278        .unwrap();
279        videre_core::db::ensure_file_hashes_columns(&conn);
280        conn
281    }
282
283    #[test]
284    fn faces_list_splits_people_clusters_singletons() {
285        let conn = seed();
286        let d = faces_list(&conn).unwrap();
287        assert_eq!(d.people.len(), 1);
288        assert_eq!(d.people[0].label, "Alice");
289        assert_eq!(
290            d.people[0].representative_id, 1,
291            "primary face is representative"
292        );
293        assert_eq!(d.clusters.len(), 1);
294        assert_eq!(d.clusters[0].cluster_id, 7);
295        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
296        assert_eq!(d.singletons.len(), 1);
297        assert_eq!(d.singletons[0].face_id, 5);
298    }
299
300    #[test]
301    fn person_detail_marks_primary() {
302        let conn = seed();
303        let p = person_detail(&conn, "Alice").unwrap();
304        assert_eq!(p.faces.len(), 2);
305        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
306        assert!(!p.faces[1].is_primary);
307    }
308
309    #[test]
310    fn cluster_detail_lists_faces() {
311        let conn = seed();
312        let c = cluster_detail(&conn, 7).unwrap();
313        assert_eq!(c.cluster_id, 7);
314        assert_eq!(
315            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
316            vec![3, 4]
317        );
318    }
319
320    #[test]
321    fn assign_labels_and_confirms() {
322        let conn = seed();
323        assign(&conn, &[3, 4], "Bob").unwrap();
324        let p = person_detail(&conn, "Bob").unwrap();
325        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
326    }
327
328    #[test]
329    fn assign_rejects_empty_label() {
330        let conn = seed();
331        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
332    }
333
334    #[test]
335    fn remove_face_unassigns_everything() {
336        let conn = seed();
337        remove_face(&conn, 1).unwrap();
338        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
339            .query_row(
340                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
341                [],
342                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
343            )
344            .unwrap();
345        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
346    }
347
348    #[test]
349    fn dissolve_cluster_nulls_cluster_id() {
350        let conn = seed();
351        dissolve_cluster(&conn, 7).unwrap();
352        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
353        assert_eq!(
354            faces_list(&conn).unwrap().singletons.len(),
355            3,
356            "3,4 join 5 as singletons"
357        );
358    }
359
360    #[test]
361    fn delete_person_unassigns_without_touching_cluster() {
362        let conn = seed();
363        // Give one of Alice's faces a cluster_id so we can prove delete_person
364        // leaves cluster_id intact (it must, so the face rejoins its cluster's
365        // unassigned group rather than scattering to singletons).
366        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
367            .unwrap();
368        delete_person(&conn, "Alice").unwrap();
369        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
370        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
371            .query_row(
372                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
373                [],
374                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
375            )
376            .unwrap();
377        assert_eq!(cid, Some(42), "cluster_id must be preserved");
378        assert_eq!(label, None, "person_label cleared");
379        assert_eq!(confirmed, 0, "confirmed cleared");
380    }
381
382    #[test]
383    fn set_primary_is_exclusive_per_person() {
384        let conn = seed();
385        set_primary(&conn, 2, "Alice").unwrap();
386        let primaries: Vec<i64> = {
387            let mut s = conn
388                .prepare("SELECT id FROM faces WHERE person_label='Alice' AND is_primary=1")
389                .unwrap();
390            s.query_map([], |r| r.get(0))
391                .unwrap()
392                .collect::<rusqlite::Result<_>>()
393                .unwrap()
394        };
395        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
396    }
397
398    #[test]
399    fn rename_missing_person_is_not_found() {
400        let conn = seed();
401        assert!(matches!(
402            rename_person(&conn, "Nobody", "X"),
403            Err(Error::NotFound)
404        ));
405    }
406
407    #[test]
408    fn rename_onto_existing_person_conflicts() {
409        let conn = seed();
410        assign(&conn, &[3], "Bob").unwrap(); // Bob now exists
411        assert!(matches!(
412            rename_person(&conn, "Alice", "Bob"),
413            Err(Error::Conflict)
414        ));
415    }
416
417    #[test]
418    fn rename_succeeds() {
419        let conn = seed();
420        rename_person(&conn, "Alice", "Alicia").unwrap();
421        assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
422        assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
423    }
424
425    #[test]
426    fn rename_to_empty_label_is_invalid() {
427        let conn = seed();
428        assert!(matches!(
429            rename_person(&conn, "Alice", "   "),
430            Err(Error::Invalid)
431        ));
432    }
433}