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>, pub cluster_id: Option<i64>,
12 pub person_label: Option<String>,
13 pub confirmed: i64,
14 pub is_primary: i64,
15 pub det_score: f32,
24 pub blur: f32,
25 pub oriented: bool,
32}
33
34pub fn ensure_people_table(conn: &Connection) {
43 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 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 let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN is_primary INTEGER DEFAULT 0");
88 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 let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN oriented INTEGER");
97
98 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
111pub 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
121pub 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
128pub 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
205pub fn load_faces_for_clustering(
212 conn: &Connection,
213) -> rusqlite::Result<Vec<(i64, Vec<f32>, f32, Option<String>, Option<f32>)>> {
214 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
238fn 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
271pub type LabeledFace = (i64, String, String, bool);
278
279pub type LabeledFacesByHash = HashMap<String, Vec<LabeledFace>>;
282
283pub fn labeled_faces_by_hash(conn: &Connection) -> rusqlite::Result<LabeledFacesByHash> {
288 let mut stmt = conn.prepare(
289 "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 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 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 mark_scanned(&conn, "noface").unwrap();
550 assert_eq!(scanned_hashes(&conn).unwrap(), vec!["noface".to_string()]);
551 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 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 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 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
638pub 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 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(); 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 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 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 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 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 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 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 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 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 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 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 let m = labeled_faces_by_hash(&db()).unwrap();
992 assert!(!m.contains_key("h3"));
993 }
994}