Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of how recognisable the crop was: a blur, an occlusion or a hard profile comes out short. Normalising threw it away. A short vector sits near the middle of the sphere and matches a little of everyone, which is how one bad crop bridges two people in a grouping pass. So the length is kept — the store now holds the raw vector, re-normalised on load, with the length beside it as `faces.quality` — and a face under MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and placed where it fits, but never what another face is measured against. Two probes are never paired, and a probe is nobody's evidence for a confidence. The People screen shows the number as "Quality 17.3", dimmed below the floor. Faces indexed before this stored unit vectors and have no reading; they are admitted to the gallery, and schema V14 forgets the run marker of every image holding one so the next indexing pass measures them. A peer's unmeasured shard faces are not adopted, or a sync would write that marker back.
This commit is contained in:
@@ -76,6 +76,9 @@ pub struct SharedFace {
|
||||
pub confidence: f32,
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See `faces::DetectedFace::quality`. `None` from a shard written before
|
||||
/// the number was kept.
|
||||
pub quality: Option<f32>,
|
||||
/// The face cut out and encoded, or empty where none was kept.
|
||||
///
|
||||
/// Travels with the face rather than in the catalog snapshot, which is the
|
||||
@@ -224,8 +227,8 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
embedding, crop_px, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
rusqlite::params![
|
||||
f.file_id as i64,
|
||||
f.model_id,
|
||||
@@ -238,6 +241,7 @@ impl FaceShardStore {
|
||||
f.embedding,
|
||||
f.crop_px as f64,
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -442,9 +446,10 @@ impl FaceShardStore {
|
||||
}
|
||||
let mut fq = src.prepare(&format!(
|
||||
"SELECT f.file_id, f.model_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.confidence, f.embedding, f.crop_px, {}
|
||||
f.confidence, f.embedding, f.crop_px, {}, {}
|
||||
FROM faces f WHERE f.file_id = ?1 AND f.model_id = ?2",
|
||||
crop_column(&src)
|
||||
column_or_null(&src, "crop"),
|
||||
column_or_null(&src, "quality"),
|
||||
))?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
||||
@@ -484,7 +489,8 @@ impl FaceShardStore {
|
||||
let Some(edge) = edge else { return Ok(None) };
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
|
||||
crop, quality
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = q
|
||||
@@ -541,6 +547,7 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
|
||||
for (table, column, decl) in [
|
||||
("faces", "crop", "BLOB"),
|
||||
("indexed", "indexed_at", "INTEGER"),
|
||||
("faces", "quality", "REAL"),
|
||||
] {
|
||||
if !has_column(conn, table, column)? {
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
|
||||
@@ -555,16 +562,19 @@ fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, Cata
|
||||
Ok(stmt.exists(rusqlite::params![table, column])?)
|
||||
}
|
||||
|
||||
/// `f.crop`, or a `NULL` standing in for it.
|
||||
/// `f.<column>`, or a `NULL` standing in for it.
|
||||
///
|
||||
/// A shard downloaded from a peer is opened **read-only** and cannot be
|
||||
/// upgraded, so one written before crops existed has to be read as it is rather
|
||||
/// than repaired. Selecting a literal keeps the column count the same, which is
|
||||
/// what lets [`read_shared_face`] stay a single function.
|
||||
fn crop_column(conn: &Connection) -> &'static str {
|
||||
match has_column(conn, "faces", "crop") {
|
||||
Ok(true) => "f.crop",
|
||||
_ => "NULL",
|
||||
/// upgraded, so one written before a column existed has to be read as it is
|
||||
/// rather than repaired. Selecting a literal keeps the column count the same,
|
||||
/// which is what lets [`read_shared_face`] stay a single function.
|
||||
///
|
||||
/// `column` is one of this module's own names, never anything read from
|
||||
/// outside, which is what makes formatting it into SQL acceptable.
|
||||
fn column_or_null(conn: &Connection, column: &'static str) -> String {
|
||||
match has_column(conn, "faces", column) {
|
||||
Ok(true) => format!("f.{column}"),
|
||||
_ => "NULL".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,7 +647,8 @@ pub fn export_to_shards_reporting(
|
||||
continue;
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
|
||||
quality
|
||||
FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -654,6 +665,7 @@ pub fn export_to_shards_reporting(
|
||||
embedding: r.get(6)?,
|
||||
crop_px: r.get::<_, f64>(7)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(8)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(9)?.map(|q| q as f32),
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -710,6 +722,14 @@ pub fn import_from_shards(
|
||||
let Some((faces, edge)) = store.get_image(file_id as u64, model_id)? else {
|
||||
continue;
|
||||
};
|
||||
// A peer that embedded before the quality was kept has done work this
|
||||
// device cannot finish: the number exists only at embedding time, and
|
||||
// adopting the faces would write the run marker that keeps them from
|
||||
// ever being measured (schema V14). Left for this device's own pass —
|
||||
// or for the peer's, whose re-export replaces these.
|
||||
if faces.iter().any(|f| f.quality.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let local: Vec<crate::faces::DetectedFace> = faces
|
||||
.into_iter()
|
||||
.map(|f| crate::faces::DetectedFace {
|
||||
@@ -721,6 +741,7 @@ pub fn import_from_shards(
|
||||
confidence: f.confidence,
|
||||
embedding: f.embedding,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
model_id: f.model_id,
|
||||
// A peer that indexed before crops existed sends none, and the
|
||||
// reader falls back to the proxy exactly as it does for a face
|
||||
@@ -766,6 +787,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
embedding: r.get(8)?,
|
||||
crop_px: r.get::<_, f64>(9)? as f32,
|
||||
crop: r.get::<_, Option<Vec<u8>>>(10)?.unwrap_or_default(),
|
||||
quality: r.get::<_, Option<f64>>(11)?.map(|q| q as f32),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -849,7 +871,11 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
crop_px REAL NOT NULL,
|
||||
-- The face, cut out. NULL where the face was found before crops were kept,
|
||||
-- or adopted from a peer that did not have one.
|
||||
crop BLOB
|
||||
crop BLOB,
|
||||
-- Length of the raw embedding (`faces::DetectedFace::quality`). NULL from
|
||||
-- a build that did not keep it, and a face the receiving device will not
|
||||
-- adopt -- see `import_from_shards`.
|
||||
quality REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
|
||||
|
||||
@@ -908,6 +934,7 @@ mod tests {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(17.5),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -925,6 +952,7 @@ mod tests {
|
||||
assert_eq!(edge, 1024);
|
||||
assert_eq!(faces[0].embedding.len(), 1024);
|
||||
assert!((faces[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(faces[0].quality, Some(17.5));
|
||||
}
|
||||
|
||||
/// The case the run marker exists for, carried across the wire: an image
|
||||
@@ -1115,6 +1143,7 @@ mod catalog_round_trip {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(20.0),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
@@ -1162,9 +1191,47 @@ mod catalog_round_trip {
|
||||
let got = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(got[0].quality, Some(20.0));
|
||||
assert!((got[0].landmarks[2].0 - 0.15).abs() < 1e-5);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert!(emb.iter().any(|(_, _, blob, _)| blob[0] == 1));
|
||||
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
||||
}
|
||||
|
||||
/// A face a peer embedded without measuring it is work this device
|
||||
/// cannot finish, and adopting it would write the marker that stops it
|
||||
/// ever being measured. The image stays outstanding instead.
|
||||
#[test]
|
||||
fn a_peers_unmeasured_faces_are_left_for_this_device_to_index() {
|
||||
let b = device(&[(90, 5001), (91, 5002)]);
|
||||
let mut store = FaceShardStore::open(&tempdir("unmeasured")).unwrap();
|
||||
let shared = |file_id: u64, quality: Option<f32>| SharedFace {
|
||||
file_id,
|
||||
model_id: "w600k_mbf".into(),
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
w: 0.15,
|
||||
h: 0.2,
|
||||
landmarks: vec![1; 40],
|
||||
confidence: 0.87,
|
||||
embedding: vec![1; 1024],
|
||||
crop_px: 180.0,
|
||||
quality,
|
||||
crop: Vec::new(),
|
||||
};
|
||||
store
|
||||
.put_image(5001, "w600k_mbf", 2560, &[shared(5001, None)])
|
||||
.unwrap();
|
||||
store
|
||||
.put_image(5002, "w600k_mbf", 2560, &[shared(5002, Some(19.0))])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
||||
let cov = faces::coverage(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(cov.indexed, 1);
|
||||
assert_eq!(cov.outstanding(), 1, "the unmeasured image was adopted");
|
||||
assert!(faces::for_image(&b, dr_types::ImageId(90))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1187,7 +1254,7 @@ mod catalog_round_trip {
|
||||
"a peer's copy replaced work this device had already done"
|
||||
);
|
||||
let emb = faces::embeddings(&b, "w600k_mbf").unwrap();
|
||||
assert_eq!(emb[0].2[0], 9, "B's own embedding was overwritten");
|
||||
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
||||
}
|
||||
|
||||
/// A device holding a subset of the library takes only its own part.
|
||||
@@ -1281,6 +1348,7 @@ mod catalog_round_trip {
|
||||
confidence: 0.87,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: None,
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -1430,6 +1498,10 @@ mod catalog_round_trip {
|
||||
let (faces, _) = store.get_image(77, "w600k_mbf").unwrap().unwrap();
|
||||
assert_eq!(faces.len(), 1);
|
||||
assert!(faces[0].crop.is_empty(), "a crop was invented from nowhere");
|
||||
assert_eq!(
|
||||
faces[0].quality, None,
|
||||
"a quality was invented from nowhere"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,10 +61,22 @@ pub struct DetectedFace {
|
||||
/// Five `(x, y)` pairs, normalised the same way.
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
/// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
|
||||
/// 512 × f16, the raw model output — `dr_face::Embedded::to_f16_bytes`.
|
||||
///
|
||||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||
/// the blob and not only beside it. Readers re-normalise on load.
|
||||
pub embedding: Vec<u8>,
|
||||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding before normalisation — the model's own
|
||||
/// reading of how recognisable the crop was, and the gate on whether
|
||||
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
|
||||
///
|
||||
/// `None` where it was never measured: a face indexed, here or by a peer,
|
||||
/// before raw vectors were stored. The unit vector those builds kept has
|
||||
/// no length left to read, so the only way to measure one is to embed it
|
||||
/// again (schema V14).
|
||||
pub quality: Option<f32>,
|
||||
/// Which model produced the embedding. Comparing across models is the one
|
||||
/// mistake that yields plausible garbage rather than an error.
|
||||
pub model_id: String,
|
||||
@@ -94,6 +106,9 @@ pub struct Face {
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||||
/// recorded.
|
||||
pub quality: Option<f32>,
|
||||
pub model_id: String,
|
||||
/// `None` when the face belongs to no one yet.
|
||||
pub person: Option<PersonId>,
|
||||
@@ -184,8 +199,8 @@ pub fn record_detections(
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||||
embedding, crop_px, model_id, detected_at, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
embedding, crop_px, model_id, detected_at, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
f.x as f64,
|
||||
@@ -202,6 +217,7 @@ pub fn record_detections(
|
||||
// the database rather than two the readers each have to know
|
||||
// about.
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
],
|
||||
)?;
|
||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||
@@ -366,7 +382,7 @@ pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, Cata
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE f.image_id = ?1
|
||||
@@ -393,9 +409,18 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
|
||||
|
||||
/// One face's stored embedding, as the clustering pass consumes it.
|
||||
///
|
||||
/// A named type rather than a tuple because it crosses a crate boundary and
|
||||
/// "the third element" is not a thing anyone should have to remember.
|
||||
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// A struct rather than a tuple because it crosses a crate boundary and "the
|
||||
/// fourth element" is not a thing anyone should have to remember.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StoredEmbedding {
|
||||
pub face: FaceId,
|
||||
pub image: ImageId,
|
||||
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`].
|
||||
pub quality: Option<f32>,
|
||||
}
|
||||
|
||||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||||
///
|
||||
@@ -404,16 +429,17 @@ pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// would double the memory of the one operation that holds them all at once.
|
||||
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT id, image_id, embedding, crop_px FROM faces
|
||||
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
||||
WHERE model_id = ?1 ORDER BY id",
|
||||
)?;
|
||||
let rows = q.query_map([model_id], |r| {
|
||||
Ok((
|
||||
FaceId(r.get::<_, i64>(0)? as u64),
|
||||
ImageId(r.get::<_, i64>(1)? as u64),
|
||||
r.get::<_, Vec<u8>>(2)?,
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
))
|
||||
Ok(StoredEmbedding {
|
||||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||||
embedding: r.get::<_, Vec<u8>>(2)?,
|
||||
crop_px: r.get::<_, f64>(3)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||
}
|
||||
@@ -643,7 +669,7 @@ pub fn for_person(
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||||
@@ -894,6 +920,7 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
|
||||
confidence: r.get::<_, f64>(7)? as f32,
|
||||
crop_px: r.get::<_, f64>(8)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
|
||||
model_id: r.get(9)?,
|
||||
person: person.map(|p| PersonId(p as u64)),
|
||||
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
|
||||
@@ -1016,6 +1043,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(10.0 + f32::from(seed)),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
@@ -1041,6 +1069,15 @@ mod tests {
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
|
||||
assert!(got[0].person.is_none());
|
||||
// Both readers carry the quality, and the one for the grouping pass
|
||||
// carries it as the option it is.
|
||||
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
|
||||
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
|
||||
let stored = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||||
assert_eq!(stored[1].quality, Some(12.0));
|
||||
}
|
||||
|
||||
/// Re-detection is coalesced per image, so it must replace rather than
|
||||
@@ -1450,10 +1487,11 @@ mod tests {
|
||||
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
|
||||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1, img);
|
||||
assert_eq!(e[0].2.len(), 1024);
|
||||
assert_eq!(e[0].2[0], 7);
|
||||
assert!((e[0].3 - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].image, img);
|
||||
assert_eq!(e[0].embedding.len(), 1024);
|
||||
assert_eq!(e[0].embedding[0], 7);
|
||||
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].quality, Some(17.0));
|
||||
}
|
||||
|
||||
// ── stored crops ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,7 +15,7 @@ use rusqlite::Connection;
|
||||
use crate::error::CatalogError;
|
||||
|
||||
/// Schema version this build writes and understands.
|
||||
pub const SCHEMA_VERSION: i64 = 13;
|
||||
pub const SCHEMA_VERSION: i64 = 14;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -119,6 +119,23 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 14 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// `ALTER TABLE ... ADD COLUMN` has no `IF NOT EXISTS`, and NFR-R5
|
||||
// wants this re-enterable: a catalog whose `user_version` was rewound
|
||||
// by a rollback already has the column, and would otherwise fail its
|
||||
// next open on it.
|
||||
let has_quality: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = 'quality'")?
|
||||
.exists([])?;
|
||||
if !has_quality {
|
||||
tx.execute_batch("ALTER TABLE faces ADD COLUMN quality REAL;")?;
|
||||
}
|
||||
tx.execute_batch(V14)?;
|
||||
tx.pragma_update(None, "user_version", 14)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
@@ -251,7 +268,8 @@ pub fn for_attached(schema_name: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}\n{}\n\
|
||||
ALTER TABLE {schema_name}.people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;",
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN crop BLOB;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name),
|
||||
rewrite_for_attached(V8, schema_name),
|
||||
@@ -569,6 +587,55 @@ CREATE TABLE IF NOT EXISTS sidecars (
|
||||
);
|
||||
"#;
|
||||
|
||||
const V14: &str = r#"
|
||||
-- TRACES: FR-CULL-9 | FR-CULL-10
|
||||
-- How recognisable the model found each face, and a second look at the faces
|
||||
-- it was never asked about.
|
||||
--
|
||||
-- The embedder's raw output has a length, and the length is a quality
|
||||
-- reading: it grows with how much of a face the model could make out, and a
|
||||
-- blur, an occlusion or a hard profile comes out short (dr_face::embedding,
|
||||
-- `MIN_GALLERY_QUALITY`). Normalising threw it away. A short vector sits
|
||||
-- near the middle of the sphere and matches a little of everyone, which is
|
||||
-- how one bad crop bridges two people in a grouping pass -- so a face below
|
||||
-- the floor is compared against the others and never compared *against*.
|
||||
--
|
||||
-- Nullable, and NULL means "never measured": every face indexed before this
|
||||
-- version stored the unit vector, whose length is one whatever the crop was.
|
||||
-- A face with no reading is admitted to the gallery, because a rule that
|
||||
-- cannot be checked should admit rather than exclude -- but it is also a
|
||||
-- face this rule is not yet protecting anyone from, and the only way to
|
||||
-- measure it is to embed it again.
|
||||
--
|
||||
-- So the run markers of every image that holds a face are forgotten, exactly
|
||||
-- as V12 forgot the runs made against too small a proxy, and for the same
|
||||
-- reason: the work list is "images with no marker", and an image examined
|
||||
-- without this number is otherwise indistinguishable from one examined with
|
||||
-- it. The faces stay where they are and keep drawing the People screen until
|
||||
-- the next pass replaces them, `record_detections` carries the user's
|
||||
-- confirmations across by box overlap, and an image that was examined and
|
||||
-- found empty keeps its marker -- there is nothing on it to re-measure.
|
||||
--
|
||||
-- The cost is a re-fetch of every image with a face on it, on the next pass
|
||||
-- the user starts. That is a whole-library transfer (FR-NC-6), and it starts
|
||||
-- when they say so, not here.
|
||||
--
|
||||
-- From this version the `embedding` blob is the **raw** model output rather
|
||||
-- than the unit vector V8 describes -- the length is the quality, and a store
|
||||
-- that kept only the direction had thrown it away. Readers re-normalise on
|
||||
-- load, so a unit blob from before and a raw blob from now compare alike;
|
||||
-- `quality` is that length kept beside the blob for the readers that never
|
||||
-- load the vector, and NULL rather than 1.0 for the old rows, because a unit
|
||||
-- vector reads as a length of one and one is not "unmeasured".
|
||||
--
|
||||
-- The column itself is added in `migrate`, guarded, because ALTER has no
|
||||
-- IF NOT EXISTS and this step has to be re-enterable (NFR-R5).
|
||||
DELETE FROM face_index
|
||||
WHERE EXISTS (SELECT 1 FROM faces f
|
||||
WHERE f.image_id = face_index.image_id
|
||||
AND f.model_id = face_index.model_id);
|
||||
"#;
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
@@ -648,7 +715,7 @@ CREATE TABLE faces (
|
||||
x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL,
|
||||
landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise
|
||||
detector_confidence REAL NOT NULL,
|
||||
embedding BLOB NOT NULL, -- 512 x f16, L2-normalised
|
||||
embedding BLOB NOT NULL, -- 512 x f16; unit length until V14, raw since
|
||||
-- Source pixels across the aligned 112x112 crop (docs/faces.md §7).
|
||||
--
|
||||
-- Not cosmetic: it is the honest quality signal for the UI, a feature in
|
||||
@@ -1405,6 +1472,64 @@ mod tests {
|
||||
assert_eq!(kept, vec![3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v14_forgets_runs_that_found_faces_but_never_measured_them() {
|
||||
let c = mem();
|
||||
c.pragma_update(None, "user_version", 0).unwrap();
|
||||
migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (1,1,'a',0),(2,1,'b',0),(3,1,'c',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Image 1 was examined and holds a face; 2 was examined and found
|
||||
// empty; 3 holds a face found by a different model.
|
||||
for (image, model) in [(1, "m"), (2, "m"), (3, "m")] {
|
||||
c.execute(
|
||||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||||
VALUES (?1, ?2, 0, 0, 2560)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for (image, model) in [(1, "m"), (3, "other")] {
|
||||
c.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence, embedding,
|
||||
crop_px, model_id, detected_at)
|
||||
VALUES (?1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, ?2, 0)",
|
||||
rusqlite::params![image, model],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
c.pragma_update(None, "user_version", 13).unwrap();
|
||||
|
||||
migrate(&c).unwrap();
|
||||
|
||||
let kept: Vec<i64> = c
|
||||
.prepare("SELECT image_id FROM face_index ORDER BY image_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
// 1 goes: it has a face with no quality. 2 stays: nothing on it to
|
||||
// measure. 3 stays: its face belongs to a run this marker does not
|
||||
// describe.
|
||||
assert_eq!(kept, vec![2, 3]);
|
||||
// And the faces themselves are untouched.
|
||||
let faces: i64 = c
|
||||
.query_row("SELECT count(*) FROM faces", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(faces, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_uniqueness_coalesces_rather_than_duplicating() {
|
||||
let c = mem();
|
||||
|
||||
Reference in New Issue
Block a user