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:
@@ -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