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:
@@ -79,6 +79,9 @@ pub struct SharedFace {
|
||||
/// See `faces::DetectedFace::quality`. `None` from a shard written before
|
||||
/// the number was kept.
|
||||
pub quality: Option<f32>,
|
||||
/// See `faces::DetectedFace::eyes`. `None` from a peer without the eye
|
||||
/// models, or a shard written before they existed.
|
||||
pub eyes: Option<dr_face::EyeReading>,
|
||||
/// 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
|
||||
@@ -250,8 +253,10 @@ impl FaceShardStore {
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(file_id, model_id, x, y, w, h, landmarks, confidence,
|
||||
embedding, crop_px, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
embedding, crop_px, crop, quality,
|
||||
eye_right, eye_left, sunglasses)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
|
||||
?13, ?14, ?15)",
|
||||
rusqlite::params![
|
||||
f.file_id as i64,
|
||||
f.model_id,
|
||||
@@ -265,6 +270,9 @@ impl FaceShardStore {
|
||||
f.crop_px as f64,
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
f.eyes.map(|e| f64::from(e.right_open)),
|
||||
f.eyes.map(|e| f64::from(e.left_open)),
|
||||
f.eyes.map(|e| f64::from(e.sunglasses)),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -469,10 +477,13 @@ 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",
|
||||
column_or_null(&src, "crop"),
|
||||
column_or_null(&src, "quality"),
|
||||
column_or_null(&src, "eye_right"),
|
||||
column_or_null(&src, "eye_left"),
|
||||
column_or_null(&src, "sunglasses"),
|
||||
))?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
.query_map(rusqlite::params![file_id, &model_id], read_shared_face)?
|
||||
@@ -513,7 +524,7 @@ impl FaceShardStore {
|
||||
|
||||
let mut q = conn.prepare(
|
||||
"SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px,
|
||||
crop, quality
|
||||
crop, quality, eye_right, eye_left, sunglasses
|
||||
FROM faces WHERE file_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = q
|
||||
@@ -571,6 +582,9 @@ fn upgrade_shard(conn: &Connection) -> Result<(), CatalogError> {
|
||||
("faces", "crop", "BLOB"),
|
||||
("indexed", "indexed_at", "INTEGER"),
|
||||
("faces", "quality", "REAL"),
|
||||
("faces", "eye_right", "REAL"),
|
||||
("faces", "eye_left", "REAL"),
|
||||
("faces", "sunglasses", "REAL"),
|
||||
] {
|
||||
if !has_column(conn, table, column)? {
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
|
||||
@@ -677,7 +691,7 @@ pub fn export_to_shards_reporting(
|
||||
}
|
||||
let mut fq = conn.prepare(
|
||||
"SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop,
|
||||
quality
|
||||
quality, eye_right, eye_left, sunglasses
|
||||
FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||||
)?;
|
||||
let faces: Vec<SharedFace> = fq
|
||||
@@ -695,6 +709,7 @@ pub fn export_to_shards_reporting(
|
||||
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),
|
||||
eyes: crate::faces::read_eyes(r, 10)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
@@ -789,6 +804,12 @@ pub fn import_from_shards(
|
||||
// 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.
|
||||
//
|
||||
// A missing *eye* reading is not the same case and is adopted. The
|
||||
// measuring pass finds those by the NULL, not by the marker, so
|
||||
// adopting the faces costs the reading nothing (schema V16) — and a
|
||||
// peer that has no eye models may be the only one that has done the
|
||||
// detection at all.
|
||||
if faces.iter().any(|f| f.quality.is_none()) {
|
||||
continue;
|
||||
}
|
||||
@@ -804,6 +825,7 @@ pub fn import_from_shards(
|
||||
embedding: f.embedding,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
eyes: f.eyes,
|
||||
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
|
||||
@@ -850,6 +872,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<SharedFace> {
|
||||
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),
|
||||
eyes: crate::faces::read_eyes(r, 12)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -937,7 +960,13 @@ CREATE TABLE IF NOT EXISTS faces (
|
||||
-- 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
|
||||
quality REAL,
|
||||
-- The eye reading (`faces::DetectedFace::eyes`), all three or none. NULL
|
||||
-- from a peer without the eye models; adopted anyway, and read by the
|
||||
-- receiving device's own measuring pass if it has them.
|
||||
eye_right REAL,
|
||||
eye_left REAL,
|
||||
sunglasses REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id);
|
||||
|
||||
@@ -997,6 +1026,7 @@ mod tests {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(17.5),
|
||||
eyes: None,
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
@@ -1206,6 +1236,7 @@ mod catalog_round_trip {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(20.0),
|
||||
eyes: None,
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
@@ -1321,6 +1352,7 @@ mod catalog_round_trip {
|
||||
embedding: vec![1; 1024],
|
||||
crop_px: 180.0,
|
||||
quality,
|
||||
eyes: None,
|
||||
crop: Vec::new(),
|
||||
};
|
||||
store
|
||||
@@ -1507,6 +1539,7 @@ mod catalog_round_trip {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: None,
|
||||
eyes: None,
|
||||
crop: vec![seed; 64],
|
||||
}
|
||||
}
|
||||
|
||||
+189
-14
@@ -58,6 +58,7 @@ use rusqlite::{Connection, OptionalExtension};
|
||||
use dr_types::ImageId;
|
||||
|
||||
use crate::error::CatalogError;
|
||||
use dr_face::EyeReading;
|
||||
|
||||
/// The embedder half of a model id: what makes two faces comparable.
|
||||
///
|
||||
@@ -120,6 +121,12 @@ pub struct DetectedFace {
|
||||
/// no length left to read, so the only way to measure one is to embed it
|
||||
/// again (schema V14).
|
||||
pub quality: Option<f32>,
|
||||
/// TRACES: FR-CULL-13
|
||||
/// What the eyes are doing — P(open) per eye and P(sunglasses), read by
|
||||
/// `dr_face::classify` from the same aligned crop. `None` where the eye
|
||||
/// models were not present at indexing, or the face came from a peer
|
||||
/// that had none; the measuring pass fills it in (schema V16).
|
||||
pub eyes: Option<EyeReading>,
|
||||
/// Which model produced the embedding. Comparing across models is the one
|
||||
/// mistake that yields plausible garbage rather than an error.
|
||||
pub model_id: String,
|
||||
@@ -152,6 +159,8 @@ pub struct Face {
|
||||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||||
/// recorded.
|
||||
pub quality: Option<f32>,
|
||||
/// See [`DetectedFace::eyes`]. `None` for a face never read.
|
||||
pub eyes: Option<EyeReading>,
|
||||
pub model_id: String,
|
||||
/// `None` when the face belongs to no one yet.
|
||||
pub person: Option<PersonId>,
|
||||
@@ -256,8 +265,10 @@ 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, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
embedding, crop_px, model_id, detected_at, crop, quality,
|
||||
eye_right, eye_left, sunglasses)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
|
||||
?14, ?15, ?16)",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
f.x as f64,
|
||||
@@ -275,6 +286,9 @@ pub fn record_detections(
|
||||
// about.
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
f.eyes.map(|e| f64::from(e.right_open)),
|
||||
f.eyes.map(|e| f64::from(e.left_open)),
|
||||
f.eyes.map(|e| f64::from(e.sunglasses)),
|
||||
],
|
||||
)?;
|
||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||
@@ -332,6 +346,9 @@ pub struct Measurement {
|
||||
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
|
||||
pub embedding: Vec<u8>,
|
||||
pub quality: f32,
|
||||
/// See [`DetectedFace::eyes`]. `None` where the measuring device has no
|
||||
/// eye models, in which case the stored reading is left as it was.
|
||||
pub eyes: Option<EyeReading>,
|
||||
}
|
||||
|
||||
/// Write fresh embeddings over faces that were found before their quality was
|
||||
@@ -368,6 +385,19 @@ pub fn record_measurements(
|
||||
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
|
||||
rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)],
|
||||
)?;
|
||||
// Written only when read: a device without the eye models measuring
|
||||
// a face a peer had already read must not blank the reading.
|
||||
if let Some(e) = m.eyes {
|
||||
tx.execute(
|
||||
"UPDATE faces SET eye_right = ?2, eye_left = ?3, sunglasses = ?4 WHERE id = ?1",
|
||||
rusqlite::params![
|
||||
m.face.0 as i64,
|
||||
f64::from(e.right_open),
|
||||
f64::from(e.left_open),
|
||||
f64::from(e.sunglasses)
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for f in dropped {
|
||||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||||
@@ -399,30 +429,45 @@ pub fn record_measurements(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The faces on one image that have no quality reading yet.
|
||||
/// The faces on one image that have no quality reading yet — or, when the
|
||||
/// device can read eyes, no eye reading either.
|
||||
///
|
||||
/// The measuring pass's per-image work: every face this model found whose
|
||||
/// vector was stored as a unit one (schema V14), with the landmarks the
|
||||
/// warp is rebuilt from.
|
||||
/// warp is rebuilt from; and, with `eyes`, every face never shown to the eye
|
||||
/// models (schema V16). `eyes` is whether this device *has* those models —
|
||||
/// a device without them must not list faces it cannot measure, or the pass
|
||||
/// would fetch every original in the library to do nothing to it.
|
||||
pub fn unmeasured_on_image(
|
||||
conn: &Connection,
|
||||
image_id: ImageId,
|
||||
model_id: &str,
|
||||
eyes: bool,
|
||||
) -> Result<Vec<Face>, CatalogError> {
|
||||
Ok(for_image(conn, image_id)?
|
||||
.into_iter()
|
||||
.filter(|f| embedder_of(&f.model_id) == embedder_of(model_id) && f.quality.is_none())
|
||||
.filter(|f| {
|
||||
embedder_of(&f.model_id) == embedder_of(model_id)
|
||||
&& (f.quality.is_none() || (eyes && f.eyes.is_none()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// How many of a model's faces have no quality reading.
|
||||
/// How many of a model's faces have no quality reading, or — with `eyes` —
|
||||
/// no eye reading.
|
||||
///
|
||||
/// What the measuring pass has left to do, for a screen that wants to say so.
|
||||
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||||
/// `eyes` means what it means in [`unmeasured_on_image`].
|
||||
pub fn faces_unmeasured(
|
||||
conn: &Connection,
|
||||
model_id: &str,
|
||||
eyes: bool,
|
||||
) -> Result<u64, CatalogError> {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND quality IS NULL",
|
||||
embedder_sql("model_id")
|
||||
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND {}",
|
||||
embedder_sql("model_id"),
|
||||
unmeasured_sql("", eyes)
|
||||
),
|
||||
[embedder_of(model_id)],
|
||||
|r| r.get::<_, i64>(0),
|
||||
@@ -559,7 +604,8 @@ 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, f.quality
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality,
|
||||
f.eye_right, f.eye_left, f.sunglasses
|
||||
FROM faces f
|
||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE f.image_id = ?1
|
||||
@@ -852,7 +898,8 @@ 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, f.quality
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality,
|
||||
f.eye_right, f.eye_left, f.sunglasses
|
||||
FROM faces f
|
||||
JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||||
@@ -1112,6 +1159,7 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
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),
|
||||
eyes: read_eyes(r, 14)?,
|
||||
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,
|
||||
@@ -1119,6 +1167,44 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The three eye columns at `first`, `first + 1`, `first + 2`, as one reading.
|
||||
///
|
||||
/// All three or none: they are written together, and a row with one of them
|
||||
/// NULL is a row nothing in this crate produced. Read as absent rather than
|
||||
/// invented, which is what a reader of a half-written row deserves.
|
||||
pub(crate) fn read_eyes(
|
||||
r: &rusqlite::Row<'_>,
|
||||
first: usize,
|
||||
) -> rusqlite::Result<Option<EyeReading>> {
|
||||
let right: Option<f64> = r.get(first)?;
|
||||
let left: Option<f64> = r.get(first + 1)?;
|
||||
let sunglasses: Option<f64> = r.get(first + 2)?;
|
||||
Ok(match (right, left, sunglasses) {
|
||||
(Some(right), Some(left), Some(sunglasses)) => Some(EyeReading {
|
||||
right_open: right as f32,
|
||||
left_open: left as f32,
|
||||
sunglasses: sunglasses as f32,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The predicate "this face still needs measuring", over `faces` aliased as
|
||||
/// `prefix` (`"f."` or `""`).
|
||||
///
|
||||
/// One place for it because two queries ask — the count above and the
|
||||
/// sweep's work list in `dr_ui::library` — and the two agreeing is what
|
||||
/// makes the pass converge: a face the count reports is a face the list
|
||||
/// fetches, and a face the list fetches is one whose reading the write
|
||||
/// fills, so it leaves both.
|
||||
pub fn unmeasured_sql(prefix: &str, eyes: bool) -> String {
|
||||
if eyes {
|
||||
format!("({prefix}quality IS NULL OR {prefix}eye_right IS NULL)")
|
||||
} else {
|
||||
format!("{prefix}quality IS NULL")
|
||||
}
|
||||
}
|
||||
|
||||
fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(40);
|
||||
for &(x, y) in lm {
|
||||
@@ -1235,6 +1321,11 @@ mod tests {
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(10.0 + f32::from(seed)),
|
||||
eyes: Some(EyeReading {
|
||||
right_open: 0.9,
|
||||
left_open: 0.8,
|
||||
sunglasses: 0.1,
|
||||
}),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
@@ -1269,6 +1360,84 @@ mod tests {
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||||
assert_eq!(stored[1].quality, Some(12.0));
|
||||
// And the eyes, as one reading.
|
||||
assert_eq!(got[0].eyes.map(|e| e.left_open), Some(0.8));
|
||||
assert_eq!(
|
||||
got[0].eyes.map(|e| e.state()),
|
||||
Some(dr_face::EyeState::Open)
|
||||
);
|
||||
}
|
||||
|
||||
/// The three eye columns are one fact: a face with none of them reads as
|
||||
/// unread, and the measuring pass is what fills them.
|
||||
#[test]
|
||||
fn eyes_are_measured_only_where_the_device_can_read_them() {
|
||||
let c = db();
|
||||
let img = image(&c, 1);
|
||||
let unread = DetectedFace {
|
||||
eyes: None,
|
||||
..face(1)
|
||||
};
|
||||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[unread, face(2)]).unwrap();
|
||||
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
||||
|
||||
// Quality is present on both, so a device without the eye models has
|
||||
// nothing to do here; one with them has one face to read.
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0);
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1);
|
||||
assert_eq!(
|
||||
unmeasured_on_image(&c, img, "w600k_mbf", false)
|
||||
.unwrap()
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
let todo = unmeasured_on_image(&c, img, "w600k_mbf", true).unwrap();
|
||||
assert_eq!(todo.len(), 1);
|
||||
assert_eq!(todo[0].id, ids[0]);
|
||||
|
||||
// A measurement with no reading leaves the columns alone …
|
||||
record_measurements(
|
||||
&c,
|
||||
img,
|
||||
"w600k_mbf",
|
||||
6000,
|
||||
&[Measurement {
|
||||
face: ids[0],
|
||||
embedding: vec![9; 1024],
|
||||
quality: 21.5,
|
||||
eyes: None,
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1);
|
||||
|
||||
// … and one with a reading fills them.
|
||||
record_measurements(
|
||||
&c,
|
||||
img,
|
||||
"w600k_mbf",
|
||||
6000,
|
||||
&[Measurement {
|
||||
face: ids[0],
|
||||
embedding: vec![9; 1024],
|
||||
quality: 21.5,
|
||||
eyes: Some(EyeReading {
|
||||
right_open: 0.2,
|
||||
left_open: 0.9,
|
||||
sunglasses: 0.0,
|
||||
}),
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 0);
|
||||
let got = for_image(&c, img).unwrap();
|
||||
let read = got.iter().find(|f| f.id == ids[0]).unwrap();
|
||||
assert_eq!(
|
||||
read.eyes.map(|e| e.state()),
|
||||
Some(dr_face::EyeState::Closed)
|
||||
);
|
||||
}
|
||||
|
||||
/// The measuring pass writes over the vector and nothing else: the face
|
||||
@@ -1291,8 +1460,13 @@ mod tests {
|
||||
.unwrap();
|
||||
let person = create_person(&c, "Anna").unwrap();
|
||||
confirm(&c, ids[0], person).unwrap();
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2);
|
||||
assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").unwrap().len(), 2);
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 2);
|
||||
assert_eq!(
|
||||
unmeasured_on_image(&c, img, "w600k_mbf", false)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let marked_at: i64 = c
|
||||
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
@@ -1308,6 +1482,7 @@ mod tests {
|
||||
face: ids[0],
|
||||
embedding: vec![9; 1024],
|
||||
quality: 21.5,
|
||||
eyes: None,
|
||||
}],
|
||||
&[ids[1]],
|
||||
)
|
||||
@@ -1321,7 +1496,7 @@ mod tests {
|
||||
assert!(got[0].confirmed);
|
||||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(e[0].embedding[0], 9);
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 0);
|
||||
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0);
|
||||
|
||||
// The marker says one face at the native edge, and is fresh — which
|
||||
// is what makes the sync export it again.
|
||||
|
||||
@@ -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