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        conn
280    }
281
282    #[test]
283    fn faces_list_splits_people_clusters_singletons() {
284        let conn = seed();
285        let d = faces_list(&conn).unwrap();
286        assert_eq!(d.people.len(), 1);
287        assert_eq!(d.people[0].label, "Alice");
288        assert_eq!(
289            d.people[0].representative_id, 1,
290            "primary face is representative"
291        );
292        assert_eq!(d.clusters.len(), 1);
293        assert_eq!(d.clusters[0].cluster_id, 7);
294        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
295        assert_eq!(d.singletons.len(), 1);
296        assert_eq!(d.singletons[0].face_id, 5);
297    }
298
299    #[test]
300    fn person_detail_marks_primary() {
301        let conn = seed();
302        let p = person_detail(&conn, "Alice").unwrap();
303        assert_eq!(p.faces.len(), 2);
304        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
305        assert!(!p.faces[1].is_primary);
306    }
307
308    #[test]
309    fn cluster_detail_lists_faces() {
310        let conn = seed();
311        let c = cluster_detail(&conn, 7).unwrap();
312        assert_eq!(c.cluster_id, 7);
313        assert_eq!(
314            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
315            vec![3, 4]
316        );
317    }
318
319    #[test]
320    fn assign_labels_and_confirms() {
321        let conn = seed();
322        assign(&conn, &[3, 4], "Bob").unwrap();
323        let p = person_detail(&conn, "Bob").unwrap();
324        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
325    }
326
327    #[test]
328    fn assign_rejects_empty_label() {
329        let conn = seed();
330        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
331    }
332
333    #[test]
334    fn remove_face_unassigns_everything() {
335        let conn = seed();
336        remove_face(&conn, 1).unwrap();
337        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
338            .query_row(
339                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
340                [],
341                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
342            )
343            .unwrap();
344        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
345    }
346
347    #[test]
348    fn dissolve_cluster_nulls_cluster_id() {
349        let conn = seed();
350        dissolve_cluster(&conn, 7).unwrap();
351        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
352        assert_eq!(
353            faces_list(&conn).unwrap().singletons.len(),
354            3,
355            "3,4 join 5 as singletons"
356        );
357    }
358
359    #[test]
360    fn delete_person_unassigns_without_touching_cluster() {
361        let conn = seed();
362        // Give one of Alice's faces a cluster_id so we can prove delete_person
363        // leaves cluster_id intact (it must, so the face rejoins its cluster's
364        // unassigned group rather than scattering to singletons).
365        conn.execute("UPDATE faces SET cluster_id = 42 WHERE id = 1", [])
366            .unwrap();
367        delete_person(&conn, "Alice").unwrap();
368        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
369        let (cid, label, confirmed): (Option<i64>, Option<String>, i64) = conn
370            .query_row(
371                "SELECT cluster_id, person_label, confirmed FROM faces WHERE id = 1",
372                [],
373                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
374            )
375            .unwrap();
376        assert_eq!(cid, Some(42), "cluster_id must be preserved");
377        assert_eq!(label, None, "person_label cleared");
378        assert_eq!(confirmed, 0, "confirmed cleared");
379    }
380
381    #[test]
382    fn set_primary_is_exclusive_per_person() {
383        let conn = seed();
384        set_primary(&conn, 2, "Alice").unwrap();
385        let primaries: Vec<i64> = {
386            let mut s = conn
387                .prepare("SELECT id FROM faces WHERE person_label='Alice' AND is_primary=1")
388                .unwrap();
389            s.query_map([], |r| r.get(0))
390                .unwrap()
391                .collect::<rusqlite::Result<_>>()
392                .unwrap()
393        };
394        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
395    }
396
397    #[test]
398    fn rename_missing_person_is_not_found() {
399        let conn = seed();
400        assert!(matches!(
401            rename_person(&conn, "Nobody", "X"),
402            Err(Error::NotFound)
403        ));
404    }
405
406    #[test]
407    fn rename_onto_existing_person_conflicts() {
408        let conn = seed();
409        assign(&conn, &[3], "Bob").unwrap(); // Bob now exists
410        assert!(matches!(
411            rename_person(&conn, "Alice", "Bob"),
412            Err(Error::Conflict)
413        ));
414    }
415
416    #[test]
417    fn rename_succeeds() {
418        let conn = seed();
419        rename_person(&conn, "Alice", "Alicia").unwrap();
420        assert_eq!(person_detail(&conn, "Alicia").unwrap().faces.len(), 2);
421        assert_eq!(person_detail(&conn, "Alice").unwrap().faces.len(), 0);
422    }
423
424    #[test]
425    fn rename_to_empty_label_is_invalid() {
426        let conn = seed();
427        assert!(matches!(
428            rename_person(&conn, "Alice", "   "),
429            Err(Error::Invalid)
430        ));
431    }
432}