Keep each face's eye reading in the catalog and in its shard
Three nullable columns beside quality — P(open) for each eye and P(sunglasses) — because the verdict is a rule with thresholds in it and a rule belongs in code, not in rows that would have to be re-measured. NULL is "never read": a face from before the models, or from a device without them, and every reader treats it as unknown rather than as closed. The measuring pass V14 built for the embedding's length is what fills them, so the sweep's work list now also names faces with no eye reading — but only on a device that has the models, or it would fetch every original to do nothing to it. A peer's shard without the reading is still adopted, unlike one without the quality: the pass finds this work by the NULL rather than by the run marker, so adoption costs it nothing.
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 = 15;
|
||||
pub const SCHEMA_VERSION: i64 = 16;
|
||||
|
||||
/// Apply migrations up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
@@ -143,9 +143,32 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if from < 16 {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
// Guarded like V14's column, and for the same reason: `ALTER TABLE
|
||||
// ... ADD COLUMN` has no `IF NOT EXISTS`, and this step must be
|
||||
// re-enterable (NFR-R5).
|
||||
for column in EYE_COLUMNS {
|
||||
let present: bool = tx
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = ?1")?
|
||||
.exists([column])?;
|
||||
if !present {
|
||||
tx.execute_batch(&format!("ALTER TABLE faces ADD COLUMN {column} REAL;"))?;
|
||||
}
|
||||
}
|
||||
tx.pragma_update(None, "user_version", 16)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(from)
|
||||
}
|
||||
|
||||
/// The three columns V16 adds to `faces`, in the order the readers name them.
|
||||
///
|
||||
/// Named once because three places have to agree on them: this migration,
|
||||
/// [`for_attached`], and the face shard's own catch-up (`face_shard`).
|
||||
pub const EYE_COLUMNS: [&str; 3] = ["eye_right", "eye_left", "sunglasses"];
|
||||
|
||||
/// Recompute columns a migration added, for rows that predate it.
|
||||
///
|
||||
/// A migration adds a column with a default; it cannot know what the value
|
||||
@@ -303,7 +326,10 @@ pub fn for_attached(schema_name: &str) -> String {
|
||||
"{}\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;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;",
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN quality REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_right REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN eye_left REAL;\n\
|
||||
ALTER TABLE {schema_name}.faces ADD COLUMN sunglasses REAL;",
|
||||
rewrite_for_attached(V1, schema_name),
|
||||
rewrite_for_attached(V6, schema_name),
|
||||
rewrite_for_attached(V8, schema_name),
|
||||
@@ -706,6 +732,33 @@ CREATE TABLE IF NOT EXISTS xmp_conflicts (
|
||||
);
|
||||
"#;
|
||||
|
||||
// V16 -- TRACES: FR-CULL-13
|
||||
//
|
||||
// What each face's eyes are doing: P(open) for the subject's right and left
|
||||
// eye, and P(sunglasses) for the head, each a sigmoid from a classifier run
|
||||
// over the aligned crop at indexing time (dr_face::classify). Three numbers
|
||||
// rather than a verdict, because the verdict is a rule with thresholds in it
|
||||
// (dr_face::eyes::EyeReading::state) and a rule belongs in code that can be
|
||||
// changed, not in rows that would have to be re-measured.
|
||||
//
|
||||
// Sunglasses are a column of their own and not folded into the eye numbers,
|
||||
// because the eye classifier answers confidently over dark glass and its
|
||||
// answer means nothing there. A filter for "eyes open" reads all three.
|
||||
//
|
||||
// NULL means "never measured" -- a face indexed before this version, or on a
|
||||
// device without the eye models -- and a NULL is left alone by every filter
|
||||
// that reads these, so an old library does not empty its grid the moment the
|
||||
// chip is pressed. The sweep's measuring pass fills them in, from the native
|
||||
// render, with the landmarks already stored: the same pass V14 built for the
|
||||
// embedding's length, extended to ask the eye models too. No run marker is
|
||||
// forgotten here, for the reason V14's note gives -- the measuring pass
|
||||
// finds its own work by the NULL, and deleting markers would only put the
|
||||
// detector back over images it has finished with.
|
||||
//
|
||||
// The columns are added in `migrate`, guarded, because ALTER has no IF NOT
|
||||
// EXISTS and the step has to be re-enterable (NFR-R5). Their names are
|
||||
// `EYE_COLUMNS`.
|
||||
|
||||
const V9: &str = r#"
|
||||
-- TRACES: FR-CULL-8
|
||||
-- A record that face detection has *run* on an image, distinct from what it
|
||||
@@ -1516,6 +1569,29 @@ mod tests {
|
||||
assert_eq!(migrate(&c).unwrap(), SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/// V16 adds its columns guarded, so a catalog whose version was rewound
|
||||
/// after the columns landed — the rollback NFR-R5 contemplates — migrates
|
||||
/// again rather than failing on "duplicate column".
|
||||
#[test]
|
||||
fn the_eye_columns_survive_a_rewound_version() {
|
||||
let c = mem();
|
||||
migrate(&c).unwrap();
|
||||
for column in EYE_COLUMNS {
|
||||
let present: bool = c
|
||||
.prepare("SELECT 1 FROM pragma_table_info('faces') WHERE name = ?1")
|
||||
.unwrap()
|
||||
.exists([column])
|
||||
.unwrap();
|
||||
assert!(present, "{column} missing after migration");
|
||||
}
|
||||
c.pragma_update(None, "user_version", 15).unwrap();
|
||||
assert_eq!(migrate(&c).unwrap(), 15);
|
||||
let v: i64 = c
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_from_a_newer_build() {
|
||||
let c = mem();
|
||||
|
||||
Reference in New Issue
Block a user