1use half::f16;
2use rusqlite::Connection;
3use std::collections::HashMap;
4
5pub struct FaceRow {
6 pub hash: String,
7 pub bbox: String,
8 pub landmark: Option<String>,
9 pub embedding: Vec<u8>, pub cluster_id: Option<i64>,
11 pub person_label: Option<String>,
12 pub confirmed: i64,
13 pub is_primary: i64,
14}
15
16pub fn create_faces_table(conn: &Connection) -> rusqlite::Result<()> {
17 conn.execute_batch(
18 "CREATE TABLE IF NOT EXISTS faces (
19 id INTEGER PRIMARY KEY,
20 hash TEXT NOT NULL,
21 bbox TEXT NOT NULL,
22 landmark TEXT,
23 embedding BLOB NOT NULL,
24 cluster_id INTEGER,
25 person_label TEXT,
26 confirmed INTEGER DEFAULT 0,
27 is_primary INTEGER DEFAULT 0
28 );"
29 )?;
30 let _ = conn.execute_batch("ALTER TABLE faces ADD COLUMN is_primary INTEGER DEFAULT 0");
32 conn.execute_batch(
37 "CREATE TABLE IF NOT EXISTS faces_scanned (
38 hash TEXT PRIMARY KEY,
39 scanned_at TEXT DEFAULT (datetime('now'))
40 );",
41 )?;
42 Ok(())
43}
44
45pub fn mark_scanned(conn: &Connection, hash: &str) -> rusqlite::Result<()> {
48 conn.execute("INSERT OR IGNORE INTO faces_scanned (hash) VALUES (?1)", rusqlite::params![hash])?;
49 Ok(())
50}
51
52pub fn scanned_hashes(conn: &Connection) -> rusqlite::Result<Vec<String>> {
54 let mut stmt = conn.prepare("SELECT hash FROM faces_scanned")?;
55 let rows = stmt.query_map([], |r| r.get(0))?;
56 rows.collect()
57}
58
59pub fn select_unscanned(
64 all: &[(String, String)],
65 skip: &std::collections::HashSet<String>,
66 limit: Option<usize>,
67) -> Vec<(String, String)> {
68 let mut seen = std::collections::HashSet::new();
69 let mut out = Vec::new();
70 for (path, hash) in all {
71 if skip.contains(hash) || !seen.insert(hash.clone()) {
72 continue;
73 }
74 out.push((path.clone(), hash.clone()));
75 if let Some(n) = limit {
76 if out.len() >= n {
77 break;
78 }
79 }
80 }
81 out
82}
83
84pub fn replace_faces_for_hash(conn: &Connection, hash: &str, faces: &[FaceRow]) -> rusqlite::Result<()> {
85 conn.execute_batch("BEGIN")?;
86 let result = (|| -> rusqlite::Result<()> {
87 conn.execute("DELETE FROM faces WHERE hash = ?1", rusqlite::params![hash])?;
88 for face in faces {
89 conn.execute(
90 "INSERT INTO faces (hash, bbox, landmark, embedding, cluster_id, person_label, confirmed, is_primary)
91 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
92 rusqlite::params![
93 face.hash, face.bbox, face.landmark, face.embedding,
94 face.cluster_id, face.person_label, face.confirmed, face.is_primary
95 ],
96 )?;
97 }
98 Ok(())
99 })();
100 match result {
101 Ok(()) => { conn.execute_batch("COMMIT")?; Ok(()) }
102 Err(e) => { let _ = conn.execute_batch("ROLLBACK"); Err(e) }
103 }
104}
105
106pub fn load_face_embeddings(conn: &Connection) -> rusqlite::Result<Vec<(i64, Vec<f32>)>> {
107 let mut stmt = conn.prepare("SELECT id, embedding FROM faces")?;
108 let rows = stmt.query_map([], |row| {
109 let id: i64 = row.get(0)?;
110 let blob: Vec<u8> = row.get(1)?;
111 Ok((id, blob))
112 })?;
113 let mut out = Vec::new();
114 for row in rows {
115 let (id, blob) = row?;
116 let emb: Vec<f32> = blob
117 .chunks_exact(2)
118 .map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32())
119 .collect();
120 out.push((id, emb));
121 }
122 Ok(out)
123}
124
125pub fn load_faces_for_clustering(conn: &Connection) -> rusqlite::Result<Vec<(i64, Vec<f32>, f32)>> {
132 let mut stmt = conn.prepare("SELECT id, embedding, bbox FROM faces")?;
133 let rows = stmt.query_map([], |row| {
134 let id: i64 = row.get(0)?;
135 let blob: Vec<u8> = row.get(1)?;
136 let bbox: String = row.get(2)?;
137 Ok((id, blob, bbox))
138 })?;
139 let mut out = Vec::new();
140 for row in rows {
141 let (id, blob, bbox) = row?;
142 let emb: Vec<f32> = blob
143 .chunks_exact(2)
144 .map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32())
145 .collect();
146 out.push((id, emb, bbox_min_side(&bbox)));
147 }
148 Ok(out)
149}
150
151fn bbox_min_side(bbox: &str) -> f32 {
154 let nums: Vec<f32> = bbox.split(',').filter_map(|s| s.trim().parse().ok()).collect();
155 if nums.len() >= 4 { nums[2].min(nums[3]) } else { 0.0 }
156}
157
158pub fn update_cluster_assignments(conn: &Connection, assignments: &[(i64, Option<i64>)]) -> rusqlite::Result<()> {
159 for (face_id, cluster_id) in assignments {
160 conn.execute(
161 "UPDATE faces SET cluster_id = ?1 WHERE id = ?2",
162 rusqlite::params![cluster_id, face_id],
163 )?;
164 }
165 Ok(())
166}
167
168pub fn hashes_with_faces(conn: &Connection) -> rusqlite::Result<Vec<String>> {
169 let mut stmt = conn.prepare("SELECT DISTINCT hash FROM faces ORDER BY hash")?;
170 let rows = stmt.query_map([], |r| r.get(0))?;
171 rows.collect()
172}
173
174pub type LabeledFace = (i64, String, String);
176
177pub type LabeledFacesByHash = HashMap<String, Vec<LabeledFace>>;
180
181pub fn labeled_faces_by_hash(conn: &Connection) -> rusqlite::Result<LabeledFacesByHash> {
186 let mut stmt = conn.prepare(
187 "SELECT hash, id, bbox, person_label FROM faces \
188 WHERE confirmed = 1 AND person_label IS NOT NULL \
189 ORDER BY hash, id",
190 )?;
191 let rows = stmt.query_map([], |r| {
192 Ok((
193 r.get::<_, String>(0)?,
194 r.get::<_, i64>(1)?,
195 r.get::<_, String>(2)?,
196 r.get::<_, String>(3)?,
197 ))
198 })?;
199 let mut map: LabeledFacesByHash = HashMap::new();
200 for row in rows {
201 let (hash, id, bbox, label) = row?;
202 map.entry(hash).or_default().push((id, label, bbox));
203 }
204 Ok(map)
205}
206
207#[cfg(test)]
208fn make_embedding(vals: &[f32]) -> Vec<u8> {
209 vals.iter().flat_map(|&v| f16::from_f32(v).to_le_bytes()).collect()
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn open() -> Connection {
217 let conn = Connection::open_in_memory().unwrap();
218 create_faces_table(&conn).unwrap();
219 conn
220 }
221
222 #[test]
223 fn create_table_idempotent() {
224 let conn = open();
225 create_faces_table(&conn).unwrap();
226 }
227
228 #[test]
229 fn insert_and_load_embedding() {
230 let conn = open();
231 let emb = make_embedding(&vec![0.5f32; 512]);
232 replace_faces_for_hash(&conn, "habc", &[FaceRow {
233 hash: "habc".into(), bbox: "0,0,50,50".into(), landmark: None,
234 embedding: emb, cluster_id: None, person_label: None, confirmed: 0, is_primary: 0,
235 }]).unwrap();
236 let rows = load_face_embeddings(&conn).unwrap();
237 assert_eq!(rows.len(), 1);
238 let (id, emb_f32) = &rows[0];
239 assert!(*id > 0);
240 assert_eq!(emb_f32.len(), 512);
241 assert!((emb_f32[0] - 0.5).abs() < 0.01);
242 }
243
244 #[test]
245 fn replace_removes_old_rows_for_same_hash() {
246 let conn = open();
247 let emb = make_embedding(&vec![0.0f32; 512]);
248 replace_faces_for_hash(&conn, "h1", &[
249 FaceRow { hash: "h1".into(), bbox: "0,0,10,10".into(), landmark: None, embedding: emb.clone(), cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 },
250 FaceRow { hash: "h1".into(), bbox: "20,0,10,10".into(), landmark: None, embedding: emb.clone(), cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 },
251 ]).unwrap();
252 replace_faces_for_hash(&conn, "h1", &[
253 FaceRow { hash: "h1".into(), bbox: "99,0,10,10".into(), landmark: None, embedding: emb, cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 },
254 ]).unwrap();
255 let rows = load_face_embeddings(&conn).unwrap();
256 assert_eq!(rows.len(), 1);
257 }
258
259 #[test]
260 fn update_cluster_assignments_works() {
261 let conn = open();
262 let emb = make_embedding(&vec![0.0f32; 512]);
263 replace_faces_for_hash(&conn, "h1", &[FaceRow { hash: "h1".into(), bbox: "0,0,10,10".into(), landmark: None, embedding: emb, cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 }]).unwrap();
264 let rows = load_face_embeddings(&conn).unwrap();
265 let id = rows[0].0;
266 update_cluster_assignments(&conn, &[(id, Some(3))]).unwrap();
267 let n: i64 = conn.query_row("SELECT cluster_id FROM faces WHERE id=?1", [id], |r| r.get(0)).unwrap();
268 assert_eq!(n, 3);
269 }
270
271 #[test]
272 fn load_faces_for_clustering_returns_bbox_min_side() {
273 let conn = open();
274 let emb = make_embedding(&vec![0.25f32; 512]);
275 replace_faces_for_hash(&conn, "h1", &[
277 FaceRow { hash: "h1".into(), bbox: "10,10,200,300".into(), landmark: None, embedding: emb.clone(), cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 },
278 FaceRow { hash: "h1".into(), bbox: "0,0,40,25".into(), landmark: None, embedding: emb, cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 },
279 ]).unwrap();
280 let mut rows = load_faces_for_clustering(&conn).unwrap();
281 rows.sort_by(|a, b| b.2.total_cmp(&a.2));
282 assert_eq!(rows[0].2, 200.0, "min side of 200x300 bbox");
283 assert_eq!(rows[1].2, 25.0, "min side of 40x25 bbox");
284 assert_eq!(rows[0].1.len(), 512, "embedding still decoded");
285 }
286
287 #[test]
288 fn mark_scanned_records_hash_even_with_zero_faces() {
289 let conn = open();
290 mark_scanned(&conn, "noface").unwrap();
293 assert_eq!(scanned_hashes(&conn).unwrap(), vec!["noface".to_string()]);
294 assert!(hashes_with_faces(&conn).unwrap().is_empty());
296 }
297
298 #[test]
299 fn mark_scanned_is_idempotent() {
300 let conn = open();
301 mark_scanned(&conn, "h").unwrap();
302 mark_scanned(&conn, "h").unwrap();
303 assert_eq!(scanned_hashes(&conn).unwrap().len(), 1);
304 }
305
306 #[test]
307 fn select_unscanned_skips_dedups_and_limits() {
308 let all = vec![
310 ("/1.jpg".to_string(), "a".to_string()),
311 ("/1copy.jpg".to_string(), "a".to_string()),
312 ("/2.jpg".to_string(), "b".to_string()),
313 ("/3.jpg".to_string(), "c".to_string()),
314 ("/4.jpg".to_string(), "d".to_string()),
315 ("/5.jpg".to_string(), "e".to_string()),
316 ];
317 let skip: std::collections::HashSet<String> = ["b".to_string()].into_iter().collect();
318 let out = select_unscanned(&all, &skip, None);
320 assert_eq!(
321 out.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
322 vec!["a", "c", "d", "e"]
323 );
324 let out2 = select_unscanned(&all, &skip, Some(2));
326 assert_eq!(
327 out2.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(),
328 vec!["a", "c"]
329 );
330 }
331
332 #[test]
333 fn hashes_with_faces_returns_inserted_hash() {
334 let conn = open();
335 let emb = make_embedding(&vec![0.0f32; 512]);
336 replace_faces_for_hash(&conn, "myhash", &[FaceRow { hash: "myhash".into(), bbox: "0,0,10,10".into(), landmark: None, embedding: emb, cluster_id: None, person_label: None, confirmed: 0, is_primary: 0 }]).unwrap();
337 let hashes = hashes_with_faces(&conn).unwrap();
338 assert_eq!(hashes, vec!["myhash"]);
339 }
340
341 #[test]
342 fn labeled_faces_by_hash_returns_only_confirmed_labeled() {
343 let conn = Connection::open_in_memory().unwrap();
344 create_faces_table(&conn).unwrap();
345 conn.execute_batch(
346 "INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
347 VALUES ('h1', '0,0,10,10', X'0000', 'Alice', 1); \
348 INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
349 VALUES ('h1', '20,20,10,10', X'0000', NULL, 0); \
350 INSERT INTO faces (hash, bbox, embedding, person_label, confirmed) \
351 VALUES ('h2', '0,0,10,10', X'0000', 'Bob', 1);",
352 )
353 .unwrap();
354
355 let map = labeled_faces_by_hash(&conn).unwrap();
356 assert_eq!(map.len(), 2, "expected two hashes with labeled faces");
357 let h1 = &map["h1"];
358 assert_eq!(h1.len(), 1, "unconfirmed/unlabeled face must be excluded");
359 assert_eq!(h1[0].1, "Alice");
360 assert_eq!(map["h2"][0].1, "Bob");
361 }
362}