Merge: one face population per embedder, whichever detector found them
This commit is contained in:
@@ -149,6 +149,29 @@ impl FaceShardStore {
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pipeline this store holds an image under, among those sharing
|
||||||
|
/// `model_id`'s embedder — the most recently indexed where a peer has
|
||||||
|
/// sent more than one.
|
||||||
|
///
|
||||||
|
/// What the import asks: not "has anyone run *this* detector over it" but
|
||||||
|
/// "does anyone hold comparable faces for it". See `faces::embedder_of`.
|
||||||
|
pub fn held_model(&self, file_id: u64, model_id: &str) -> Option<String> {
|
||||||
|
self.index
|
||||||
|
.query_row(
|
||||||
|
&format!(
|
||||||
|
"SELECT model_id FROM entries
|
||||||
|
WHERE file_id = ?1 AND {} = ?2
|
||||||
|
ORDER BY indexed_at DESC NULLS LAST, model_id",
|
||||||
|
crate::faces::embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
rusqlite::params![file_id as i64, crate::faces::embedder_of(model_id)],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
|
pub fn contains(&self, file_id: u64, model_id: &str) -> bool {
|
||||||
self.index
|
self.index
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -608,16 +631,21 @@ pub fn export_to_shards_reporting(
|
|||||||
model_id: &str,
|
model_id: &str,
|
||||||
progress: &mut dyn FnMut(usize, usize),
|
progress: &mut dyn FnMut(usize, usize),
|
||||||
) -> Result<usize, CatalogError> {
|
) -> Result<usize, CatalogError> {
|
||||||
let mut q = conn.prepare(
|
// Every pipeline sharing this one's embedder, each image under the id
|
||||||
"SELECT r.file_id, fi.image_id, fi.source_edge, fi.indexed_at
|
// that actually indexed it. A device that switched detectors still holds
|
||||||
|
// most of its library under the previous id, and those faces are exactly
|
||||||
|
// as comparable — and as wanted by a peer — as the new ones.
|
||||||
|
let mut q = conn.prepare(&format!(
|
||||||
|
"SELECT r.file_id, fi.image_id, fi.source_edge, fi.indexed_at, fi.model_id
|
||||||
FROM face_index fi
|
FROM face_index fi
|
||||||
JOIN remote r ON r.image_id = fi.image_id
|
JOIN remote r ON r.image_id = fi.image_id
|
||||||
WHERE fi.model_id = ?1
|
WHERE {} = ?1
|
||||||
ORDER BY fi.image_id",
|
ORDER BY fi.image_id",
|
||||||
)?;
|
crate::faces::embedder_sql("fi.model_id")
|
||||||
let rows: Vec<(i64, i64, i64, i64)> = q
|
))?;
|
||||||
.query_map([model_id], |r| {
|
let rows: Vec<(i64, i64, i64, i64, String)> = q
|
||||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
.query_map([crate::faces::embedder_of(model_id)], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
|
||||||
})?
|
})?
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
@@ -627,7 +655,8 @@ pub fn export_to_shards_reporting(
|
|||||||
|
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
let mut exported = 0;
|
let mut exported = 0;
|
||||||
for (seen, (file_id, image_id, edge, indexed_at)) in rows.into_iter().enumerate() {
|
for (seen, (file_id, image_id, edge, indexed_at, model_id)) in rows.into_iter().enumerate() {
|
||||||
|
let model_id = model_id.as_str();
|
||||||
if seen.is_multiple_of(REPORT_EVERY) {
|
if seen.is_multiple_of(REPORT_EVERY) {
|
||||||
progress(seen, total);
|
progress(seen, total);
|
||||||
}
|
}
|
||||||
@@ -689,10 +718,20 @@ pub fn export_to_shards_reporting(
|
|||||||
/// adopted rather than re-detected, which is the difference between a new
|
/// adopted rather than re-detected, which is the difference between a new
|
||||||
/// device being useful in a minute and in two hours.
|
/// device being useful in a minute and in two hours.
|
||||||
///
|
///
|
||||||
/// Skips any image this device has already indexed itself. Local work is not
|
/// Skips any image this device has already indexed itself under this
|
||||||
/// second-guessed by a peer's — the two should agree, since the same model over
|
/// pipeline or any sharing its embedder — unless the peer ran a detector that
|
||||||
/// the same proxy is deterministic, but where they do not, the copy this device
|
/// outranks the one that indexed it here. Local work is not second-guessed
|
||||||
/// computed is the one it can vouch for.
|
/// by a peer's equal: the two should agree, since the same model over the
|
||||||
|
/// same proxy is deterministic, and where they do not, the copy this device
|
||||||
|
/// computed is the one it can vouch for. A peer's *stronger* pass is another
|
||||||
|
/// matter: it is the re-detection this device's own sweep would queue
|
||||||
|
/// (`FaceDetector::supersedes`), already done, and taking it is what spares
|
||||||
|
/// a tablet the fetch. Names survive the replacement by box overlap, as they
|
||||||
|
/// do a local re-detection.
|
||||||
|
///
|
||||||
|
/// A peer's faces are taken under whichever compatible detector found them:
|
||||||
|
/// a tablet set to the fast detector adopts the desktop's thorough pass
|
||||||
|
/// rather than re-detecting it worse.
|
||||||
///
|
///
|
||||||
/// Returns how many images were adopted.
|
/// Returns how many images were adopted.
|
||||||
pub fn import_from_shards(
|
pub fn import_from_shards(
|
||||||
@@ -700,26 +739,49 @@ pub fn import_from_shards(
|
|||||||
store: &FaceShardStore,
|
store: &FaceShardStore,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<usize, CatalogError> {
|
) -> Result<usize, CatalogError> {
|
||||||
|
use dr_types::FaceDetector;
|
||||||
|
|
||||||
// Only images this device actually has. A shard covers the whole account,
|
// Only images this device actually has. A shard covers the whole account,
|
||||||
// and a device holding a subset of the library should take only its own
|
// and a device holding a subset of the library should take only its own
|
||||||
// part rather than accumulating faces for photographs it cannot show.
|
// part rather than accumulating faces for photographs it cannot show.
|
||||||
let mut q = conn.prepare(
|
//
|
||||||
"SELECT r.file_id, r.image_id
|
// With the pipeline that indexed each one here, or NULL: the marker is
|
||||||
|
// what decides whether a peer's copy is a gap filled or an upgrade.
|
||||||
|
let mut q = conn.prepare(&format!(
|
||||||
|
"SELECT r.file_id, r.image_id,
|
||||||
|
(SELECT fi.model_id FROM face_index fi
|
||||||
|
WHERE fi.image_id = r.image_id AND {} = ?1)
|
||||||
FROM remote r
|
FROM remote r
|
||||||
JOIN images i ON i.id = r.image_id
|
JOIN images i ON i.id = r.image_id
|
||||||
WHERE i.trashed_at IS NULL
|
WHERE i.trashed_at IS NULL",
|
||||||
AND NOT EXISTS (
|
crate::faces::embedder_sql("fi.model_id")
|
||||||
SELECT 1 FROM face_index fi
|
))?;
|
||||||
WHERE fi.image_id = r.image_id AND fi.model_id = ?1
|
let candidates: Vec<(i64, i64, Option<String>)> = q
|
||||||
)",
|
.query_map([crate::faces::embedder_of(model_id)], |r| {
|
||||||
)?;
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||||
let candidates: Vec<(i64, i64)> = q
|
})?
|
||||||
.query_map([model_id], |r| Ok((r.get(0)?, r.get(1)?)))?
|
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
let mut adopted = 0;
|
let mut adopted = 0;
|
||||||
for (file_id, image_id) in candidates {
|
for (file_id, image_id, local) in candidates {
|
||||||
let Some((faces, edge)) = store.get_image(file_id as u64, model_id)? else {
|
let Some(held) = store.held_model(file_id as u64, model_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(local) = local {
|
||||||
|
// An unknown detector on either side cannot be ranked, and an
|
||||||
|
// unranked peer is treated as an equal: kept out.
|
||||||
|
let upgrade = match (
|
||||||
|
FaceDetector::for_model_id(&held),
|
||||||
|
FaceDetector::for_model_id(&local),
|
||||||
|
) {
|
||||||
|
(Some(theirs), Some(ours)) => theirs.outranks(ours),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !upgrade {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some((faces, edge)) = store.get_image(file_id as u64, &held)? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// A peer that embedded before the quality was kept has done work this
|
// A peer that embedded before the quality was kept has done work this
|
||||||
@@ -753,7 +815,7 @@ pub fn import_from_shards(
|
|||||||
crate::faces::record_detections(
|
crate::faces::record_detections(
|
||||||
conn,
|
conn,
|
||||||
dr_types::ImageId(image_id as u64),
|
dr_types::ImageId(image_id as u64),
|
||||||
model_id,
|
&held,
|
||||||
edge,
|
edge,
|
||||||
&local,
|
&local,
|
||||||
)?;
|
)?;
|
||||||
@@ -1197,6 +1259,49 @@ mod catalog_round_trip {
|
|||||||
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
assert!(emb.iter().any(|e| e.embedding[0] == 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The desktop switched to a stronger detector part-way through the
|
||||||
|
/// library, so its faces sit under two pipeline ids. A tablet on the
|
||||||
|
/// original detector must receive *all* of them — each under the id that
|
||||||
|
/// found it — and not re-detect the thorough half worse.
|
||||||
|
#[test]
|
||||||
|
fn every_generation_sharing_an_embedder_travels_and_is_adopted() {
|
||||||
|
let a = device(&[(1, 5001), (2, 5002)]);
|
||||||
|
let b = device(&[(90, 5001), (91, 5002)]);
|
||||||
|
|
||||||
|
faces::record_detections(&a, dr_types::ImageId(1), "w600k_mbf", 1024, &[detected(1)])
|
||||||
|
.unwrap();
|
||||||
|
let mut thorough = detected(2);
|
||||||
|
thorough.model_id = "scrfd_10g+w600k_mbf".into();
|
||||||
|
faces::record_detections(
|
||||||
|
&a,
|
||||||
|
dr_types::ImageId(2),
|
||||||
|
"scrfd_10g+w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[thorough],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut store_a = FaceShardStore::open(&tempdir("a")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
export_to_shards(&a, &mut store_a, "scrfd_10g+w600k_mbf").unwrap(),
|
||||||
|
2,
|
||||||
|
"the export left the earlier detector's images behind"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut store_b = FaceShardStore::open(&tempdir("b")).unwrap();
|
||||||
|
store_b.merge_shard(&store_a.shard_path(0)).unwrap();
|
||||||
|
assert_eq!(import_from_shards(&b, &store_b, "w600k_mbf").unwrap(), 2);
|
||||||
|
|
||||||
|
assert_eq!(faces::coverage(&b, "w600k_mbf").unwrap().outstanding(), 0);
|
||||||
|
let old = faces::for_image(&b, dr_types::ImageId(90)).unwrap();
|
||||||
|
let new = faces::for_image(&b, dr_types::ImageId(91)).unwrap();
|
||||||
|
assert_eq!(old[0].model_id, "w600k_mbf");
|
||||||
|
assert_eq!(
|
||||||
|
new[0].model_id, "scrfd_10g+w600k_mbf",
|
||||||
|
"adopted under the wrong id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A face a peer embedded without measuring it is work this device
|
/// A face a peer embedded without measuring it is work this device
|
||||||
/// cannot finish, and adopting it would write the marker that stops it
|
/// cannot finish, and adopting it would write the marker that stops it
|
||||||
/// ever being measured. The image stays outstanding instead.
|
/// ever being measured. The image stays outstanding instead.
|
||||||
@@ -1257,6 +1362,59 @@ mod catalog_round_trip {
|
|||||||
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
assert_eq!(emb[0].embedding[0], 9, "B's own embedding was overwritten");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A peer's stronger detector is the re-detection this device would
|
||||||
|
/// otherwise queue for itself. Taking it saves the fetch; the name the
|
||||||
|
/// user confirmed here rides across on the box, as it would locally.
|
||||||
|
#[test]
|
||||||
|
fn a_peers_stronger_pass_replaces_a_weaker_local_one_and_keeps_the_name() {
|
||||||
|
let a = device(&[(1, 5001)]);
|
||||||
|
let b = device(&[(50, 5001)]);
|
||||||
|
|
||||||
|
let ids =
|
||||||
|
faces::record_detections(&b, dr_types::ImageId(50), "w600k_mbf", 1024, &[detected(9)])
|
||||||
|
.unwrap();
|
||||||
|
let anna = faces::create_person(&b, "Anna").unwrap();
|
||||||
|
faces::confirm(&b, ids[0], anna).unwrap();
|
||||||
|
|
||||||
|
let mut thorough = detected(7);
|
||||||
|
thorough.model_id = "scrfd_10g+w600k_mbf".into();
|
||||||
|
let mut second = detected(8);
|
||||||
|
second.model_id = "scrfd_10g+w600k_mbf".into();
|
||||||
|
second.x = 0.6;
|
||||||
|
faces::record_detections(
|
||||||
|
&a,
|
||||||
|
dr_types::ImageId(1),
|
||||||
|
"scrfd_10g+w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[thorough, second],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut store = FaceShardStore::open(&tempdir("upgrade")).unwrap();
|
||||||
|
export_to_shards(&a, &mut store, "scrfd_10g+w600k_mbf").unwrap();
|
||||||
|
assert_eq!(import_from_shards(&b, &store, "w600k_mbf").unwrap(), 1);
|
||||||
|
|
||||||
|
let got = faces::for_image(&b, dr_types::ImageId(50)).unwrap();
|
||||||
|
assert_eq!(got.len(), 2, "the stronger pass was not adopted");
|
||||||
|
let named = got
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.person == Some(anna))
|
||||||
|
.expect("the name was lost");
|
||||||
|
assert!(named.confirmed);
|
||||||
|
assert_eq!(named.model_id, "scrfd_10g+w600k_mbf");
|
||||||
|
|
||||||
|
// And never downwards: A on the fast detector keeps B's thorough faces.
|
||||||
|
let mut store_b = FaceShardStore::open(&tempdir("downgrade")).unwrap();
|
||||||
|
faces::record_detections(&b, dr_types::ImageId(50), "w600k_mbf", 1024, &[detected(9)])
|
||||||
|
.unwrap();
|
||||||
|
export_to_shards(&b, &mut store_b, "w600k_mbf").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
import_from_shards(&a, &store_b, "scrfd_10g+w600k_mbf").unwrap(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(faces::for_image(&a, dr_types::ImageId(1)).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
/// A device holding a subset of the library takes only its own part.
|
/// A device holding a subset of the library takes only its own part.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_device_ignores_faces_for_photographs_it_does_not_have() {
|
fn a_device_ignores_faces_for_photographs_it_does_not_have() {
|
||||||
|
|||||||
+186
-38
@@ -26,6 +26,25 @@
|
|||||||
//! disposable index; the name is irreplaceable and goes to the sidecar
|
//! disposable index; the name is irreplaceable and goes to the sidecar
|
||||||
//! (FR-CULL-12), which is not this module's job.
|
//! (FR-CULL-12), which is not this module's job.
|
||||||
//!
|
//!
|
||||||
|
//! # One population per embedder, not one per detector
|
||||||
|
//!
|
||||||
|
//! `faces.model_id` names a pipeline, `detector+embedder`, and a detector
|
||||||
|
//! change writes new rows under a new id. What it must *not* do is split the
|
||||||
|
//! library in two: the People screen, the clustering pass, the sync merge and
|
||||||
|
//! the shard import all read "the faces" — and the faces are every row whose
|
||||||
|
//! embedder half matches, because the embedder is what makes two vectors
|
||||||
|
//! comparable and the detector only decides where the boxes are. Before this
|
||||||
|
//! rule each of those read the exact id, and choosing a better detector
|
||||||
|
//! emptied every screen until a 400 GB re-index had run on every device,
|
||||||
|
//! while a name confirmed on one device could not reach the other because the
|
||||||
|
//! two held the same face under different ids.
|
||||||
|
//!
|
||||||
|
//! So reads key on [`embedder_of`] the id, via [`embedder_sql`]; writes keep
|
||||||
|
//! the exact id, so which detector drew a box stays recorded; and
|
||||||
|
//! [`record_detections`] is where the generations meet — an image holds one
|
||||||
|
//! pipeline's faces at a time, and a re-detection carries the user's
|
||||||
|
//! confirmations onto the boxes that replace them.
|
||||||
|
//!
|
||||||
//! # Privacy is structural here, not policy
|
//! # Privacy is structural here, not policy
|
||||||
//!
|
//!
|
||||||
//! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog:
|
//! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog:
|
||||||
@@ -40,6 +59,30 @@ use dr_types::ImageId;
|
|||||||
|
|
||||||
use crate::error::CatalogError;
|
use crate::error::CatalogError;
|
||||||
|
|
||||||
|
/// The embedder half of a model id: what makes two faces comparable.
|
||||||
|
///
|
||||||
|
/// `scrfd_10g+w600k_mbf` and `w600k_mbf` are the same embedder behind two
|
||||||
|
/// detectors, and their vectors live in one space. A bare id is its own
|
||||||
|
/// embedder — the first pipeline was written without a detector prefix, and
|
||||||
|
/// every library indexed before the choice existed is under that spelling.
|
||||||
|
pub fn embedder_of(model_id: &str) -> &str {
|
||||||
|
model_id.rsplit('+').next().unwrap_or(model_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SQL for [`embedder_of`] over a column, for a `WHERE` that means "the
|
||||||
|
/// same population as this pipeline" rather than "this exact pipeline".
|
||||||
|
///
|
||||||
|
/// Callers pass `embedder_of(model_id)` as the bound value. A `CASE` rather
|
||||||
|
/// than `LIKE`, so the bare and the qualified spelling compare as the same
|
||||||
|
/// thing without a wildcard that `w600k_mbf_v2` would also match.
|
||||||
|
pub fn embedder_sql(column: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"CASE WHEN instr({column}, '+') > 0
|
||||||
|
THEN substr({column}, instr({column}, '+') + 1)
|
||||||
|
ELSE {column} END"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`].
|
/// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct FaceId(pub u64);
|
pub struct FaceId(pub u64);
|
||||||
@@ -330,8 +373,11 @@ pub fn record_measurements(
|
|||||||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||||||
}
|
}
|
||||||
let remaining: i64 = tx.query_row(
|
let remaining: i64 = tx.query_row(
|
||||||
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
&format!(
|
||||||
rusqlite::params![image_id.0 as i64, model_id],
|
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND {} = ?2",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -365,7 +411,7 @@ pub fn unmeasured_on_image(
|
|||||||
) -> Result<Vec<Face>, CatalogError> {
|
) -> Result<Vec<Face>, CatalogError> {
|
||||||
Ok(for_image(conn, image_id)?
|
Ok(for_image(conn, image_id)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|f| f.model_id == model_id && f.quality.is_none())
|
.filter(|f| embedder_of(&f.model_id) == embedder_of(model_id) && f.quality.is_none())
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +420,11 @@ pub fn unmeasured_on_image(
|
|||||||
/// What the measuring pass has left to do, for a screen that wants to say so.
|
/// 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> {
|
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL",
|
&format!(
|
||||||
[model_id],
|
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND quality IS NULL",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
[embedder_of(model_id)],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.map(|n| n as u64)
|
.map(|n| n as u64)
|
||||||
@@ -443,13 +492,19 @@ pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogEr
|
|||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
// One row per image, whichever compatible pipeline wrote it: an image
|
||||||
|
// holds one pipeline's faces at a time (`record_detections`), so the
|
||||||
|
// markers of one embedder never double-count a photograph.
|
||||||
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
|
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
|
||||||
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
|
&format!(
|
||||||
FROM face_index fi
|
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
|
||||||
JOIN images i ON i.id = fi.image_id
|
FROM face_index fi
|
||||||
WHERE fi.model_id = ?1
|
JOIN images i ON i.id = fi.image_id
|
||||||
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
|
WHERE {} = ?1
|
||||||
[model_id],
|
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
|
||||||
|
embedder_sql("fi.model_id")
|
||||||
|
),
|
||||||
|
[embedder_of(model_id)],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -461,15 +516,19 @@ pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogEr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether one image has been through this model.
|
/// Whether one image has been through this model, or one it shares an
|
||||||
|
/// embedder with.
|
||||||
pub fn is_indexed(
|
pub fn is_indexed(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
image_id: ImageId,
|
image_id: ImageId,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<bool, CatalogError> {
|
) -> Result<bool, CatalogError> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND model_id = ?2)",
|
&format!(
|
||||||
rusqlite::params![image_id.0 as i64, model_id],
|
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND {} = ?2)",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
@@ -486,8 +545,11 @@ pub fn clear_index_marker(
|
|||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<(), CatalogError> {
|
) -> Result<(), CatalogError> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM face_index WHERE image_id = ?1 AND model_id = ?2",
|
&format!(
|
||||||
rusqlite::params![image_id.0 as i64, model_id],
|
"DELETE FROM face_index WHERE image_id = ?1 AND {} = ?2",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -513,12 +575,15 @@ pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, Cata
|
|||||||
/// unassigned and still belongs in the next clustering round, just not in that
|
/// unassigned and still belongs in the next clustering round, just not in that
|
||||||
/// person's cluster.
|
/// person's cluster.
|
||||||
pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, CatalogError> {
|
pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, CatalogError> {
|
||||||
let mut q = conn.prepare(
|
let mut q = conn.prepare(&format!(
|
||||||
"SELECT f.id FROM faces f
|
"SELECT f.id FROM faces f
|
||||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||||
WHERE fp.face_id IS NULL AND f.model_id = ?1",
|
WHERE fp.face_id IS NULL AND {} = ?1",
|
||||||
)?;
|
embedder_sql("f.model_id")
|
||||||
let rows = q.query_map([model_id], |r| Ok(FaceId(r.get::<_, i64>(0)? as u64)))?;
|
))?;
|
||||||
|
let rows = q.query_map([embedder_of(model_id)], |r| {
|
||||||
|
Ok(FaceId(r.get::<_, i64>(0)? as u64))
|
||||||
|
})?;
|
||||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,15 +604,18 @@ pub struct StoredEmbedding {
|
|||||||
|
|
||||||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||||||
///
|
///
|
||||||
/// Returned as raw f16 blobs rather than decoded vectors: the caller is
|
/// Every face this model's embedder produced, whichever detector found it —
|
||||||
/// `dr-face`, which owns the decoding, and a catalog that widened them here
|
/// see the module note. Returned as raw f16 blobs rather than decoded vectors:
|
||||||
/// would double the memory of the one operation that holds them all at once.
|
/// the caller is `dr-face`, which owns the decoding, and a catalog that
|
||||||
|
/// widened them here 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> {
|
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
|
||||||
let mut q = conn.prepare(
|
let mut q = conn.prepare(&format!(
|
||||||
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
||||||
WHERE model_id = ?1 ORDER BY id",
|
WHERE {} = ?1 ORDER BY id",
|
||||||
)?;
|
embedder_sql("model_id")
|
||||||
let rows = q.query_map([model_id], |r| {
|
))?;
|
||||||
|
let rows = q.query_map([embedder_of(model_id)], |r| {
|
||||||
Ok(StoredEmbedding {
|
Ok(StoredEmbedding {
|
||||||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||||||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||||||
@@ -823,6 +891,11 @@ pub fn images_for_person(
|
|||||||
// ── calibration ───────────────────────────────────────────────────────────
|
// ── calibration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Store a fitted calibration, replacing any previous fit for the model.
|
/// Store a fitted calibration, replacing any previous fit for the model.
|
||||||
|
///
|
||||||
|
/// Keyed on the embedder: a calibration is a fit over pairwise similarities,
|
||||||
|
/// and those are the same space for every detector in front of one embedder.
|
||||||
|
/// Keying it on the full pipeline would discard the fit — and the library's
|
||||||
|
/// sharpened confidences with it — every time the detector was changed.
|
||||||
pub fn put_calibration(
|
pub fn put_calibration(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
@@ -842,7 +915,7 @@ pub fn put_calibration(
|
|||||||
face_set_hash = excluded.face_set_hash,
|
face_set_hash = excluded.face_set_hash,
|
||||||
fitted_at = excluded.fitted_at",
|
fitted_at = excluded.fitted_at",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
model_id,
|
embedder_of(model_id),
|
||||||
cal.a as f64,
|
cal.a as f64,
|
||||||
cal.b as f64,
|
cal.b as f64,
|
||||||
cal.w_size as f64,
|
cal.w_size as f64,
|
||||||
@@ -868,7 +941,7 @@ pub fn calibration(
|
|||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash
|
"SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash
|
||||||
FROM face_calibration WHERE model_id = ?1",
|
FROM face_calibration WHERE model_id = ?1",
|
||||||
[model_id],
|
[embedder_of(model_id)],
|
||||||
|r| {
|
|r| {
|
||||||
Ok((
|
Ok((
|
||||||
Calibration {
|
Calibration {
|
||||||
@@ -958,8 +1031,11 @@ pub fn crop(conn: &Connection, face: FaceId) -> Result<Option<Vec<u8>>, CatalogE
|
|||||||
/// and what a "re-index to fill these in" prompt would be counting.
|
/// and what a "re-index to fill these in" prompt would be counting.
|
||||||
pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND crop IS NULL",
|
&format!(
|
||||||
[model_id],
|
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND crop IS NULL",
|
||||||
|
embedder_sql("model_id")
|
||||||
|
),
|
||||||
|
[embedder_of(model_id)],
|
||||||
|r| r.get::<_, i64>(0),
|
|r| r.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.map(|n| n as u64)
|
.map(|n| n as u64)
|
||||||
@@ -1271,6 +1347,64 @@ mod tests {
|
|||||||
assert_eq!(for_image(&c, img).unwrap().len(), 1);
|
assert_eq!(for_image(&c, img).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_embedder_is_the_half_after_the_plus() {
|
||||||
|
assert_eq!(embedder_of("w600k_mbf"), "w600k_mbf");
|
||||||
|
assert_eq!(embedder_of("scrfd_10g+w600k_mbf"), "w600k_mbf");
|
||||||
|
assert_eq!(embedder_of("scrfd_2.5g+w600k_mbf"), "w600k_mbf");
|
||||||
|
assert_eq!(embedder_of("scrfd_10g+other"), "other");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A detector change must not split the library: the clustering pass,
|
||||||
|
/// the coverage figure and the "is this indexed" question all see every
|
||||||
|
/// generation that shares an embedder, and none of a different one.
|
||||||
|
#[test]
|
||||||
|
fn every_detector_in_front_of_one_embedder_is_one_population() {
|
||||||
|
let c = db();
|
||||||
|
let old = image(&c, 1);
|
||||||
|
let new = image(&c, 2);
|
||||||
|
let foreign = image(&c, 3);
|
||||||
|
record_detections(&c, old, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||||
|
let mut f = face(2);
|
||||||
|
f.model_id = "scrfd_10g+w600k_mbf".into();
|
||||||
|
record_detections(&c, new, "scrfd_10g+w600k_mbf", 1024, &[f]).unwrap();
|
||||||
|
let mut g = face(3);
|
||||||
|
g.model_id = "scrfd_10g+other".into();
|
||||||
|
record_detections(&c, foreign, "scrfd_10g+other", 1024, &[g]).unwrap();
|
||||||
|
|
||||||
|
for id in ["w600k_mbf", "scrfd_10g+w600k_mbf", "scrfd_2.5g+w600k_mbf"] {
|
||||||
|
assert_eq!(embeddings(&c, id).unwrap().len(), 2, "{id}");
|
||||||
|
assert_eq!(unassigned(&c, id).unwrap().len(), 2, "{id}");
|
||||||
|
assert_eq!(coverage(&c, id).unwrap().indexed, 2, "{id}");
|
||||||
|
assert!(is_indexed(&c, old, id).unwrap(), "{id}");
|
||||||
|
assert!(is_indexed(&c, new, id).unwrap(), "{id}");
|
||||||
|
assert!(!is_indexed(&c, foreign, id).unwrap(), "{id}");
|
||||||
|
}
|
||||||
|
assert_eq!(embeddings(&c, "scrfd_10g+other").unwrap().len(), 1);
|
||||||
|
assert_eq!(coverage(&c, "scrfd_10g+other").unwrap().indexed, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fit is over the embedder's similarity space, so it is the same fit
|
||||||
|
/// whichever detector is chosen — and a detector change must not discard
|
||||||
|
/// it.
|
||||||
|
#[test]
|
||||||
|
fn a_calibration_outlives_a_detector_change() {
|
||||||
|
let c = db();
|
||||||
|
let cal = Calibration {
|
||||||
|
a: 1.5,
|
||||||
|
b: -0.5,
|
||||||
|
w_size: 0.1,
|
||||||
|
valid: true,
|
||||||
|
positive_pairs: 40,
|
||||||
|
negative_pairs: 400,
|
||||||
|
};
|
||||||
|
put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap();
|
||||||
|
let (got, hash) = calibration(&c, "scrfd_10g+w600k_mbf").unwrap().unwrap();
|
||||||
|
assert_eq!(got, cal);
|
||||||
|
assert_eq!(hash, "abc");
|
||||||
|
assert!(calibration(&c, "scrfd_10g+other").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// The invariant FR-CULL-10 turns on: a re-index with a better model must
|
/// The invariant FR-CULL-10 turns on: a re-index with a better model must
|
||||||
/// not throw away the user's own labelling.
|
/// not throw away the user's own labelling.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1622,9 +1756,12 @@ mod tests {
|
|||||||
assert_eq!(edge, 2048, "the marker should record the newer proxy");
|
assert_eq!(edge, 2048, "the marker should record the newer proxy");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user who switches detector and back must not find the images the
|
/// An image holds one pipeline's faces at a time, so the first pipeline's
|
||||||
/// second pipeline visited reported as done under the first with no
|
/// marker goes with its faces — but the image stays indexed for every
|
||||||
/// faces behind the marker.
|
/// pipeline sharing the embedder, because the faces behind the marker
|
||||||
|
/// that remains are the same population. Switching detector and back
|
||||||
|
/// therefore neither re-queues the image nor leaves a marker with nothing
|
||||||
|
/// behind it.
|
||||||
#[test]
|
#[test]
|
||||||
fn re_indexing_under_another_model_drops_the_first_models_marker() {
|
fn re_indexing_under_another_model_drops_the_first_models_marker() {
|
||||||
let c = db();
|
let c = db();
|
||||||
@@ -1632,13 +1769,24 @@ mod tests {
|
|||||||
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
|
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
|
||||||
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
|
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
|
||||||
|
|
||||||
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
|
let markers: Vec<String> = {
|
||||||
assert!(
|
let mut q = c
|
||||||
!is_indexed(&c, img, "w600k_mbf").unwrap(),
|
.prepare("SELECT model_id FROM face_index WHERE image_id = ?1")
|
||||||
|
.unwrap();
|
||||||
|
q.query_map([img.0 as i64], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
markers,
|
||||||
|
vec!["scrfd_2.5g+w600k_mbf".to_string()],
|
||||||
"the first pipeline's marker outlived its faces"
|
"the first pipeline's marker outlived its faces"
|
||||||
);
|
);
|
||||||
|
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
|
||||||
|
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
|
||||||
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
||||||
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 1);
|
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -991,9 +991,13 @@ fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result<bool,
|
|||||||
/// Remote face row id to local face row id, by photograph and box overlap.
|
/// Remote face row id to local face row id, by photograph and box overlap.
|
||||||
///
|
///
|
||||||
/// See [`merge_people_within`] for why a face has no shared identity and this
|
/// See [`merge_people_within`] for why a face has no shared identity and this
|
||||||
/// has to be derived. Only faces from the same model are compared: boxes from
|
/// has to be derived. Faces are compared within an *embedder*
|
||||||
/// two different detectors are not the same measurement, and matching across
|
/// (`faces::embedder_of`), not within an exact pipeline id: two detectors in
|
||||||
/// them would attach a judgement to a face nobody looked at.
|
/// front of the same embedder draw boxes around the same faces, and a
|
||||||
|
/// confirmation made on one device's box is about the face, not the
|
||||||
|
/// rectangle — the same judgement `faces::record_detections` makes when it
|
||||||
|
/// carries a confirmation across a re-detection. Keying on the exact id was
|
||||||
|
/// what let a detector change strand every name on the device that made it.
|
||||||
fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, CatalogError> {
|
fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, CatalogError> {
|
||||||
/// Loose on purpose — "the same face in the frame", not "the same
|
/// Loose on purpose — "the same face in the frame", not "the same
|
||||||
/// rectangle". The figure `record_detections` uses for the same job.
|
/// rectangle". The figure `record_detections` uses for the same job.
|
||||||
@@ -1026,7 +1030,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
|
|||||||
})?;
|
})?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (file_id, model, boxed) = row?;
|
let (file_id, model, boxed) = row?;
|
||||||
local.entry((file_id, model)).or_default().push(boxed);
|
let embedder = crate::faces::embedder_of(&model).to_string();
|
||||||
|
local.entry((file_id, embedder)).or_default().push(boxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if local.is_empty() {
|
if local.is_empty() {
|
||||||
@@ -1056,7 +1061,8 @@ fn match_faces(tx: &Connection) -> Result<std::collections::HashMap<i64, i64>, C
|
|||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (remote_id, file_id, model, rbox) = row?;
|
let (remote_id, file_id, model, rbox) = row?;
|
||||||
let Some(candidates) = local.get(&(file_id, model)) else {
|
let embedder = crate::faces::embedder_of(&model).to_string();
|
||||||
|
let Some(candidates) = local.get(&(file_id, embedder)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let best = candidates
|
let best = candidates
|
||||||
@@ -1977,6 +1983,54 @@ mod tests {
|
|||||||
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug this rule exists for: the desktop switched to a stronger
|
||||||
|
/// detector and confirmed 3,500 faces under the old pipeline id; the
|
||||||
|
/// tablet held the same faces under the new one, and not one name
|
||||||
|
/// crossed, because the match demanded the exact id. Same photograph,
|
||||||
|
/// same box, same embedder — that is the same face.
|
||||||
|
#[test]
|
||||||
|
fn a_confirmation_crosses_a_detector_change() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_synced_image(&c, db, 1, 5000);
|
||||||
|
}
|
||||||
|
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||||
|
c.execute(
|
||||||
|
"UPDATE main.faces SET model_id = 'scrfd_10g+w600k_mbf' WHERE id = ?1",
|
||||||
|
[local],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let remote = add_face(&c, "remote_cat", 42, 1, 0.31);
|
||||||
|
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||||
|
assign(&c, "remote_cat", remote, 3, true);
|
||||||
|
|
||||||
|
let report = merge_all(&c).unwrap();
|
||||||
|
assert_eq!(report.faces_assigned, 1);
|
||||||
|
assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A different embedder is a different space, and a box there is a face
|
||||||
|
/// nobody here has a vector for.
|
||||||
|
#[test]
|
||||||
|
fn a_confirmation_does_not_cross_an_embedder_change() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_synced_image(&c, db, 1, 5000);
|
||||||
|
}
|
||||||
|
let local = add_face(&c, "main", 7, 1, 0.30);
|
||||||
|
c.execute(
|
||||||
|
"UPDATE main.faces SET model_id = 'scrfd_10g+other_embedder' WHERE id = ?1",
|
||||||
|
[local],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let remote = add_face(&c, "remote_cat", 42, 1, 0.31);
|
||||||
|
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||||
|
assign(&c, "remote_cat", remote, 3, true);
|
||||||
|
|
||||||
|
merge_all(&c).unwrap();
|
||||||
|
assert_eq!(person_of(&c, local), None, "matched across embedders");
|
||||||
|
}
|
||||||
|
|
||||||
/// Boxes from two devices are close but not identical. Matching has to be
|
/// Boxes from two devices are close but not identical. Matching has to be
|
||||||
/// by overlap, not equality, or nothing ever lines up.
|
/// by overlap, not equality, or nothing ever lines up.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -186,17 +186,26 @@ pub struct FaceSettings {
|
|||||||
/// and a tablet on a battery want different answers — so it is a setting,
|
/// and a tablet on a battery want different answers — so it is a setting,
|
||||||
/// per device, like the rest of this file.
|
/// per device, like the rest of this file.
|
||||||
///
|
///
|
||||||
/// # A detector is half of a model id
|
/// # A detector is half of a model id, and the half that does not split
|
||||||
|
/// # the library
|
||||||
///
|
///
|
||||||
/// Every face row, run marker, sync shard and calibration is keyed by
|
/// Every face row, run marker, sync shard and calibration carries
|
||||||
/// `faces.model_id` (catalog.md §10.1), and the schema's whole reason for
|
/// `faces.model_id` (catalog.md §10.1), spelled `detector+embedder`, so which
|
||||||
/// carrying that column is that *a model change is a new id and a re-index*
|
/// pipeline produced a face is always on record. But every reader of "the
|
||||||
/// rather than a silent change under existing data. A detector change is a
|
/// faces" keys on the *embedder* half (`dr_catalog::faces::embedder_of`):
|
||||||
/// model change: it decides which faces exist and where the landmarks that
|
/// the embedder is what makes two vectors comparable, and the detector only
|
||||||
/// align them land. So each variant names its own pipeline, and choosing
|
/// decides where the boxes are. So the three variants here are one
|
||||||
/// another one puts every image back in the queue and shows the People screen
|
/// population, and choosing another one empties nothing — the People screen,
|
||||||
/// for the new pipeline — empty until the sweep has run, with confirmed names
|
/// the clustering and the sync all go on over every face already found.
|
||||||
/// carried across by `record_detections`' box overlap.
|
/// What a stronger choice does is queue the images a weaker one indexed for
|
||||||
|
/// re-detection, after the ones nothing has indexed ([`Self::supersedes`]),
|
||||||
|
/// with confirmed names carried across by `record_detections`' box overlap.
|
||||||
|
///
|
||||||
|
/// The first version of this setting keyed everything on the full id, and
|
||||||
|
/// choosing Thorough emptied both devices' People screens until a whole-
|
||||||
|
/// library re-index had run on each — and, worse, stranded every name
|
||||||
|
/// confirmed on one device, because the other held the same faces under a
|
||||||
|
/// different id and the merge would not match them.
|
||||||
///
|
///
|
||||||
/// The first variant's id is the bare embedder name, because that is the id
|
/// The first variant's id is the bare embedder name, because that is the id
|
||||||
/// every library indexed before this setting existed was written under;
|
/// every library indexed before this setting existed was written under;
|
||||||
@@ -251,6 +260,41 @@ impl FaceDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The detector that writes under a pipeline id, if it is one of these.
|
||||||
|
///
|
||||||
|
/// The inverse of [`Self::model_id`]. `None` for an id from another
|
||||||
|
/// embedder or a build this one does not know, which a caller treats as
|
||||||
|
/// "cannot rank" rather than as weaker than anything.
|
||||||
|
pub fn for_model_id(model_id: &str) -> Option<FaceDetector> {
|
||||||
|
FaceDetector::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.model_id() == model_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this detector finds more than `other` does — the measured
|
||||||
|
/// order of §12.3, which is also the order of [`Self::ALL`].
|
||||||
|
pub fn outranks(self, other: FaceDetector) -> bool {
|
||||||
|
let rank = |d: FaceDetector| FaceDetector::ALL.iter().position(|x| *x == d);
|
||||||
|
rank(self) > rank(other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipeline ids this detector is worth re-running over.
|
||||||
|
///
|
||||||
|
/// Every variant shares one embedder, so a library indexed under any of
|
||||||
|
/// them is one population (`dr_catalog::faces::embedder_of`) and a
|
||||||
|
/// detector change empties nothing. What a stronger detector *adds* is
|
||||||
|
/// the faces a weaker one missed, so a sweep re-detects the images a
|
||||||
|
/// weaker one indexed — after the ones nothing has indexed — and never
|
||||||
|
/// the other way round: a tablet set to Fast keeps the desktop's
|
||||||
|
/// Thorough faces rather than replacing them with fewer.
|
||||||
|
pub fn supersedes(self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
FaceDetector::Scrfd500m => &[],
|
||||||
|
FaceDetector::Scrfd2_5g => &["w600k_mbf"],
|
||||||
|
FaceDetector::Scrfd10g => &["w600k_mbf", "scrfd_2.5g+w600k_mbf"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What the picker calls it.
|
/// What the picker calls it.
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -1682,6 +1726,26 @@ mod tests {
|
|||||||
/// The detector every existing library was indexed with must keep the id
|
/// The detector every existing library was indexed with must keep the id
|
||||||
/// those libraries were written under, or an upgrade would report every
|
/// those libraries were written under, or an upgrade would report every
|
||||||
/// one of them un-indexed.
|
/// one of them un-indexed.
|
||||||
|
#[test]
|
||||||
|
fn detectors_rank_in_the_measured_order_and_only_supersede_downwards() {
|
||||||
|
use FaceDetector::*;
|
||||||
|
assert!(Scrfd10g.outranks(Scrfd2_5g));
|
||||||
|
assert!(Scrfd2_5g.outranks(Scrfd500m));
|
||||||
|
assert!(!Scrfd500m.outranks(Scrfd10g));
|
||||||
|
assert!(!Scrfd10g.outranks(Scrfd10g));
|
||||||
|
for d in FaceDetector::ALL {
|
||||||
|
for weaker in d.supersedes() {
|
||||||
|
let w = FaceDetector::for_model_id(weaker).expect(weaker);
|
||||||
|
assert!(
|
||||||
|
d.outranks(w),
|
||||||
|
"{d:?} lists {weaker} but does not outrank it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(FaceDetector::for_model_id(d.model_id()), Some(d));
|
||||||
|
}
|
||||||
|
assert_eq!(FaceDetector::for_model_id("scrfd_10g+other"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_default_detector_keeps_the_legacy_model_id() {
|
fn the_default_detector_keeps_the_legacy_model_id() {
|
||||||
assert_eq!(FaceDetector::default(), FaceDetector::Scrfd500m);
|
assert_eq!(FaceDetector::default(), FaceDetector::Scrfd500m);
|
||||||
|
|||||||
+29
-10
@@ -540,8 +540,8 @@ images indexed, **64 contain no face at all**.
|
|||||||
|
|
||||||
So `face_index` records the *run*: one row per `(image, model)` carrying the timestamp, the number of
|
So `face_index` records the *run*: one row per `(image, model)` carrying the timestamp, the number of
|
||||||
faces found — zero is the interesting value — and the long edge of the proxy it read. Keyed on the
|
faces found — zero is the interesting value — and the long edge of the proxy it read. Keyed on the
|
||||||
model, so a model change puts every image back in the queue without anyone having to remember to
|
model, so an *embedder* change puts every image back in the queue without anyone having to remember
|
||||||
clear anything.
|
to clear anything; a detector change in front of the same embedder does not (§12.3).
|
||||||
|
|
||||||
Three things fall out of it that were not otherwise available:
|
Three things fall out of it that were not otherwise available:
|
||||||
|
|
||||||
@@ -1100,14 +1100,33 @@ is not a desktop-only feature (NFR-RES-2). It loads in tract with the same fix a
|
|||||||
decodes through the same nine-output path unchanged. Same licence, same `buffalo_m` release page.
|
decodes through the same nine-output path unchanged. Same licence, same `buffalo_m` release page.
|
||||||
|
|
||||||
**Which detector runs is a setting** — `FaceSettings::detector`, per device, on the settings page
|
**Which detector runs is a setting** — `FaceSettings::detector`, per device, on the settings page
|
||||||
beside the indexing button as Fast / Balanced / Thorough. All three files ship. Each detector is
|
beside the indexing button as Fast / Balanced / Thorough. All three files ship. Each detector
|
||||||
its own `faces.model_id` (`w600k_mbf` for `500M`, unchanged, so nothing already indexed is
|
writes its own `faces.model_id` (`w600k_mbf` for `500M`, unchanged; `scrfd_2.5g+w600k_mbf` and
|
||||||
disturbed; `scrfd_2.5g+w600k_mbf` and `scrfd_10g+w600k_mbf` for the others), which is the
|
`scrfd_10g+w600k_mbf` for the others), so which pipeline drew a box is always on record. The
|
||||||
mechanism §2.1 always intended for a model change: the coverage figure restarts at zero under the
|
default stays `500M` so that an upgrade changes nothing until the user chooses; the recommendation
|
||||||
new id, the sweep re-detects, `record_detections` carries confirmed names across by box overlap and
|
is `2.5G`.
|
||||||
drops the previous pipeline's marker for each image it revisits, and the sync shards are keyed by
|
|
||||||
the same id so a peer on another setting neither adopts nor pollutes them. The default stays `500M`
|
**One population per embedder, not one per detector · 2026-09-19.** The first cut of the setting
|
||||||
so that an upgrade changes nothing until the user chooses; the recommendation is `2.5G`.
|
keyed every reader on the full id — the clustering pass, the coverage figure, the sweep's work
|
||||||
|
list, the shard export and import, and the sync merge's face matching — on the theory that a
|
||||||
|
detector change is a model change. Measured on the reference library it was a disaster: choosing
|
||||||
|
Thorough on both devices restarted coverage at 1,834 of 19,140, the People screen showed only the
|
||||||
|
faces the new pipeline had reached, the desktop's 3,583 confirmations under the old id could not
|
||||||
|
reach the tablet because the merge demanded the same id on both sides, and each device faced a
|
||||||
|
~400 GB re-fetch before the library looked whole again. The embedder is `w600k_mbf` in every
|
||||||
|
variant; its vectors are one space, and the detector only decides where the boxes are.
|
||||||
|
|
||||||
|
So every reader now keys on the embedder half of the id (`faces::embedder_of`, and
|
||||||
|
`embedder_sql` for the queries): all three detectors are one population, and changing between
|
||||||
|
them empties nothing. `record_detections` is unchanged — an image holds one pipeline's faces at a
|
||||||
|
time, and a re-detection carries confirmations across by box overlap — and it is where the
|
||||||
|
generations meet. The merge's `match_faces` matches within an embedder for the same reason. The
|
||||||
|
shards travel every generation, each under its own id, and a peer adopts whichever it is sent.
|
||||||
|
What a stronger choice still does is queue the images a weaker detector indexed for re-detection
|
||||||
|
(`FaceDetector::supersedes`), after the ones nothing has indexed and never downwards, so a tablet
|
||||||
|
on Fast keeps the desktop's Thorough faces rather than replacing them with fewer. The calibration
|
||||||
|
(§8) is keyed on the embedder too: it is a fit over the similarity space, and that space did not
|
||||||
|
change.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+28
-28
File diff suppressed because one or more lines are too long
+13
-10
@@ -91,14 +91,15 @@ pub struct FaceRequest {
|
|||||||
/// examined, so every landscape in the library would be re-detected on every
|
/// examined, so every landscape in the library would be re-detected on every
|
||||||
/// run, for ever. See the V9 migration.
|
/// run, for ever. See the V9 migration.
|
||||||
///
|
///
|
||||||
/// Keyed on the model, so a model upgrade re-indexes rather than leaving the
|
/// Keyed on the embedder, so an embedder upgrade re-indexes rather than
|
||||||
/// library half-described by weights that are no longer comparable.
|
/// leaving the library half-described by weights that are no longer
|
||||||
|
/// comparable — and a detector change, which keeps the embedder, does not.
|
||||||
pub fn faces_outstanding(
|
pub fn faces_outstanding(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
store: &ThumbStore,
|
store: &ThumbStore,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Result<Vec<FaceRequest>, dr_catalog::CatalogError> {
|
) -> Result<Vec<FaceRequest>, dr_catalog::CatalogError> {
|
||||||
let mut stmt = catalog.connection().prepare(
|
let mut stmt = catalog.connection().prepare(&format!(
|
||||||
"SELECT i.id, r.file_id
|
"SELECT i.id, r.file_id
|
||||||
FROM images i
|
FROM images i
|
||||||
JOIN remote r ON r.image_id = i.id
|
JOIN remote r ON r.image_id = i.id
|
||||||
@@ -107,12 +108,13 @@ pub fn faces_outstanding(
|
|||||||
AND i.shadowed_by IS NULL
|
AND i.shadowed_by IS NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM face_index fi
|
SELECT 1 FROM face_index fi
|
||||||
WHERE fi.image_id = i.id AND fi.model_id = ?1
|
WHERE fi.image_id = i.id AND {} = ?1
|
||||||
)
|
)
|
||||||
ORDER BY i.id",
|
ORDER BY i.id",
|
||||||
)?;
|
faces::embedder_sql("fi.model_id")
|
||||||
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([model_id], |r| {
|
.query_map([faces::embedder_of(model_id)], |r| {
|
||||||
Ok(FaceRequest {
|
Ok(FaceRequest {
|
||||||
image_id: ImageId(r.get::<_, i64>(0)? as u64),
|
image_id: ImageId(r.get::<_, i64>(0)? as u64),
|
||||||
file_id: r.get::<_, i64>(1)? as u64,
|
file_id: r.get::<_, i64>(1)? as u64,
|
||||||
@@ -209,7 +211,7 @@ pub fn audit(
|
|||||||
// for the same reason `faces::coverage` excludes them: they are the JPEG
|
// for the same reason `faces::coverage` excludes them: they are the JPEG
|
||||||
// half of a pair, no sweep will ever index one, and counting them makes
|
// half of a pair, no sweep will ever index one, and counting them makes
|
||||||
// the outstanding figure a number that cannot reach zero.
|
// the outstanding figure a number that cannot reach zero.
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT r.file_id
|
"SELECT r.file_id
|
||||||
FROM images i
|
FROM images i
|
||||||
JOIN remote r ON r.image_id = i.id
|
JOIN remote r ON r.image_id = i.id
|
||||||
@@ -218,12 +220,13 @@ pub fn audit(
|
|||||||
AND i.shadowed_by IS NULL
|
AND i.shadowed_by IS NULL
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM face_index fi
|
SELECT 1 FROM face_index fi
|
||||||
WHERE fi.image_id = i.id AND fi.model_id = ?1
|
WHERE fi.image_id = i.id AND {} = ?1
|
||||||
)",
|
)",
|
||||||
)?;
|
faces::embedder_sql("fi.model_id")
|
||||||
|
))?;
|
||||||
let (mut ready, mut awaiting) = (0u64, 0u64);
|
let (mut ready, mut awaiting) = (0u64, 0u64);
|
||||||
for file_id in stmt
|
for file_id in stmt
|
||||||
.query_map([model_id], |r| r.get::<_, i64>(0))?
|
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
{
|
{
|
||||||
if store.contains(file_id as u64, FACE_TIER) {
|
if store.contains(file_id as u64, FACE_TIER) {
|
||||||
|
|||||||
@@ -1089,6 +1089,14 @@ pub fn wire<S, M, P>(
|
|||||||
detector,
|
detector,
|
||||||
embedder,
|
embedder,
|
||||||
model_id(&settings_for_sweep),
|
model_id(&settings_for_sweep),
|
||||||
|
settings_for_sweep
|
||||||
|
.snapshot()
|
||||||
|
.faces
|
||||||
|
.detector
|
||||||
|
.supersedes()
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.collect(),
|
||||||
dr_face::DetectOptions::default(),
|
dr_face::DetectOptions::default(),
|
||||||
gpu,
|
gpu,
|
||||||
));
|
));
|
||||||
|
|||||||
+123
-10
@@ -3800,16 +3800,18 @@ fn faces_unindexed(
|
|||||||
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM face_index fi
|
SELECT 1 FROM face_index fi
|
||||||
WHERE fi.image_id = i.id AND fi.model_id = ?1
|
WHERE fi.image_id = i.id AND {fi_embedder} = ?1
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM faces f
|
SELECT 1 FROM faces f
|
||||||
WHERE f.image_id = i.id AND f.model_id = ?1 AND f.quality IS NULL
|
WHERE f.image_id = i.id AND {f_embedder} = ?1 AND f.quality IS NULL
|
||||||
)
|
)
|
||||||
ORDER BY i.id"
|
ORDER BY i.id",
|
||||||
|
fi_embedder = dr_catalog::faces::embedder_sql("fi.model_id"),
|
||||||
|
f_embedder = dr_catalog::faces::embedder_sql("f.model_id"),
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([model_id], |r| {
|
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
|
||||||
Ok(ThumbnailRequest {
|
Ok(ThumbnailRequest {
|
||||||
// Face indexing wants the detail a thumbnail discards.
|
// Face indexing wants the detail a thumbnail discards.
|
||||||
full_resolution: true,
|
full_resolution: true,
|
||||||
@@ -3853,11 +3855,12 @@ fn faces_without_proxy(
|
|||||||
FROM images i
|
FROM images i
|
||||||
JOIN remote r ON r.image_id = i.id
|
JOIN remote r ON r.image_id = i.id
|
||||||
JOIN faces f ON f.image_id = i.id
|
JOIN faces f ON f.image_id = i.id
|
||||||
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND f.model_id = ?1
|
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {embedder} = ?1
|
||||||
ORDER BY i.id"
|
ORDER BY i.id",
|
||||||
|
embedder = dr_catalog::faces::embedder_sql("f.model_id"),
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([model_id], |r| {
|
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
|
||||||
Ok(ThumbnailRequest {
|
Ok(ThumbnailRequest {
|
||||||
full_resolution: true,
|
full_resolution: true,
|
||||||
thumb_size: dr_thumbs::ThumbSize::Large,
|
thumb_size: dr_thumbs::ThumbSize::Large,
|
||||||
@@ -3902,11 +3905,66 @@ fn faces_unmeasured(
|
|||||||
JOIN remote r ON r.image_id = i.id
|
JOIN remote r ON r.image_id = i.id
|
||||||
JOIN faces f ON f.image_id = i.id
|
JOIN faces f ON f.image_id = i.id
|
||||||
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
||||||
AND f.model_id = ?1 AND f.quality IS NULL
|
AND {embedder} = ?1 AND f.quality IS NULL
|
||||||
|
ORDER BY i.id",
|
||||||
|
embedder = dr_catalog::faces::embedder_sql("f.model_id"),
|
||||||
|
))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
|
||||||
|
Ok(ThumbnailRequest {
|
||||||
|
full_resolution: true,
|
||||||
|
thumb_size: dr_thumbs::ThumbSize::Large,
|
||||||
|
row: 0,
|
||||||
|
image_id: r.get(0)?,
|
||||||
|
path: r.get(1)?,
|
||||||
|
file_id: r.get::<_, Option<i64>>(2)?.map(|v| v as u64),
|
||||||
|
size: r.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
|
||||||
|
needs_metadata: false,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Images indexed by a detector the chosen one outranks.
|
||||||
|
///
|
||||||
|
/// The tail of the sweep's work list: behind everything nothing has looked
|
||||||
|
/// at, because a photograph with no faces recorded is worth more than one
|
||||||
|
/// whose faces a weaker detector may have under-counted. `weaker` is
|
||||||
|
/// `FaceDetector::supersedes` for the current choice, and it is a list of
|
||||||
|
/// exact ids rather than "anything else sharing the embedder" so the
|
||||||
|
/// re-detection only ever runs upwards — a device set to the fast detector
|
||||||
|
/// leaves a peer's thorough pass alone.
|
||||||
|
///
|
||||||
|
/// The user's confirmations survive the re-detection by box overlap
|
||||||
|
/// (`faces::record_detections`), which is what makes this safe to run over a
|
||||||
|
/// library that is already named.
|
||||||
|
fn faces_superseded(
|
||||||
|
catalog: &Catalog,
|
||||||
|
weaker: &[&str],
|
||||||
|
) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> {
|
||||||
|
if weaker.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let placeholders = weaker
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, _)| format!("?{}", i + 1))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let mut stmt = catalog.connection().prepare(&format!(
|
||||||
|
"SELECT i.id, i.source_ref, r.file_id, i.file_size
|
||||||
|
FROM images i
|
||||||
|
JOIN remote r ON r.image_id = i.id
|
||||||
|
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM face_index fi
|
||||||
|
WHERE fi.image_id = i.id AND fi.model_id IN ({placeholders})
|
||||||
|
)
|
||||||
ORDER BY i.id"
|
ORDER BY i.id"
|
||||||
))?;
|
))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([model_id], |r| {
|
.query_map(rusqlite::params_from_iter(weaker.iter()), |r| {
|
||||||
Ok(ThumbnailRequest {
|
Ok(ThumbnailRequest {
|
||||||
full_resolution: true,
|
full_resolution: true,
|
||||||
thumb_size: dr_thumbs::ThumbSize::Large,
|
thumb_size: dr_thumbs::ThumbSize::Large,
|
||||||
@@ -3981,6 +4039,7 @@ pub fn spawn_face_sweep(
|
|||||||
detector_model: PathBuf,
|
detector_model: PathBuf,
|
||||||
embedder_model: PathBuf,
|
embedder_model: PathBuf,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
|
supersedes: Vec<String>,
|
||||||
options: dr_face::DetectOptions,
|
options: dr_face::DetectOptions,
|
||||||
gpu: dr_gpu::GpuContext,
|
gpu: dr_gpu::GpuContext,
|
||||||
) -> Receiver<crate::faces::FaceSweepMessage> {
|
) -> Receiver<crate::faces::FaceSweepMessage> {
|
||||||
@@ -4107,13 +4166,37 @@ pub fn spawn_face_sweep(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last: what a weaker detector already indexed. Everything above is
|
||||||
|
// a photograph the People screen cannot show at all; these it shows
|
||||||
|
// already, and the re-detection only adds the faces that detector
|
||||||
|
// missed. See `faces_superseded`.
|
||||||
|
let weaker: Vec<&str> = supersedes.iter().map(String::as_str).collect();
|
||||||
|
let mut upgrades = 0usize;
|
||||||
|
match faces_superseded(&catalog, &weaker) {
|
||||||
|
Ok(older) => {
|
||||||
|
let fresh: Vec<_> = older
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| queued.insert(r.image_id))
|
||||||
|
.collect();
|
||||||
|
upgrades = fresh.len();
|
||||||
|
wanted.extend(fresh.into_iter().map(|r| (r, SweepWork::Detect)));
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("face sweep: looking for images under a weaker detector: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
let total = wanted.len();
|
let total = wanted.len();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
log::info!("face sweep: every image has been through this model");
|
log::info!("face sweep: every image has been through this model");
|
||||||
finish_empty(&tx);
|
finish_empty(&tx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!("face sweep: {total} image(s) to index");
|
if upgrades > 0 {
|
||||||
|
log::info!(
|
||||||
|
"face sweep: {total} image(s) to index, {upgrades} of them indexed by a weaker detector"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!("face sweep: {total} image(s) to index");
|
||||||
|
}
|
||||||
if tx.send(FaceSweepMessage::Total(total)).is_err() {
|
if tx.send(FaceSweepMessage::Total(total)).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6739,6 +6822,36 @@ mod tests {
|
|||||||
assert_eq!(faces_unindexed(&catalog, "other").unwrap().len(), 3);
|
assert_eq!(faces_unindexed(&catalog, "other").unwrap().len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A detector change keeps the embedder, so it is not a new library: the
|
||||||
|
/// images the old detector ran over are not "unindexed" for the new one.
|
||||||
|
/// They are an *upgrade*, listed separately and only when the chosen
|
||||||
|
/// detector outranks the one that indexed them — never the other way,
|
||||||
|
/// or a tablet on the fast detector would undo the desktop's thorough
|
||||||
|
/// pass.
|
||||||
|
#[test]
|
||||||
|
fn a_stronger_detector_upgrades_rather_than_re_indexes() {
|
||||||
|
let catalog = with_images(3);
|
||||||
|
let ids = image_ids(&catalog);
|
||||||
|
let conn = catalog.connection();
|
||||||
|
dr_catalog::faces::record_detections(conn, ids[0], "w600k_mbf", 1024, &[]).unwrap();
|
||||||
|
dr_catalog::faces::record_detections(conn, ids[1], "scrfd_10g+w600k_mbf", 1024, &[])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fresh = faces_unindexed(&catalog, "scrfd_10g+w600k_mbf").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fresh.len(),
|
||||||
|
1,
|
||||||
|
"the earlier detector's images were re-queued"
|
||||||
|
);
|
||||||
|
assert_eq!(fresh[0].image_id, ids[2].0 as i64);
|
||||||
|
|
||||||
|
// Thorough re-runs what Fast did; Fast leaves what Thorough did alone.
|
||||||
|
let up = faces_superseded(&catalog, &["w600k_mbf", "scrfd_2.5g+w600k_mbf"]).unwrap();
|
||||||
|
assert_eq!(up.len(), 1);
|
||||||
|
assert_eq!(up[0].image_id, ids[0].0 as i64);
|
||||||
|
assert!(faces_superseded(&catalog, &[]).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// The state schema V14 leaves: a face with no quality and an image with
|
/// The state schema V14 leaves: a face with no quality and an image with
|
||||||
/// no marker. It is the measuring pass's work, and *only* that pass's — a
|
/// no marker. It is the measuring pass's work, and *only* that pass's — a
|
||||||
/// full re-detection of the same image would throw away every suggestion
|
/// full re-detection of the same image would throw away every suggestion
|
||||||
|
|||||||
Reference in New Issue
Block a user