Fuse every detector's faces into one population per embedder
Choosing "Thorough" made the library look empty. The detector setting writes under its own faces.model_id, and every reader of "the faces" keyed on that exact 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 reference library that restarted coverage at 1,834 of 19,140, drew a People rail of 36 faces for a person with 520, queued a ~400 GB re-fetch on each device, and stranded the desktop's 3,583 confirmations under the old id: the tablet held the same faces under the new one and the merge refused to match them. Same photograph, same box, same embedder, two ids — that is one face, not two libraries. The embedder half of the id is now the key. embedder_of and embedder_sql give it to every query; writes keep the full id, so which detector drew a box stays on record. record_detections is unchanged and is where the generations meet: an image holds one pipeline's faces at a time, and a re-detection carries confirmations across by box overlap. The merge's match_faces applies the same rule within an embedder. The calibration is keyed on the embedder too, since the similarity space did not change. Shards travel every generation, each under its own id, and a peer adopts whichever it is sent — including a stronger detector's pass over an image it indexed itself with a weaker one, which is the re-detection its own sweep would otherwise queue, already done. Never downwards: a tablet on Fast keeps the desktop's Thorough faces. The sweep gains the same tail — images a weaker detector indexed, after the ones nothing has — driven by FaceDetector::supersedes, so choosing a stronger detector still improves the library over time without first making it disappear.
This commit is contained in:
+123
-10
@@ -3800,16 +3800,18 @@ fn faces_unindexed(
|
||||
WHERE r.file_id IS NOT NULL AND {VISIBLE}
|
||||
AND NOT EXISTS (
|
||||
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 (
|
||||
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
|
||||
.query_map([model_id], |r| {
|
||||
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
|
||||
Ok(ThumbnailRequest {
|
||||
// Face indexing wants the detail a thumbnail discards.
|
||||
full_resolution: true,
|
||||
@@ -3853,11 +3855,12 @@ fn faces_without_proxy(
|
||||
FROM images i
|
||||
JOIN remote r ON r.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
|
||||
ORDER BY i.id"
|
||||
WHERE r.file_id IS NOT NULL AND {VISIBLE} AND {embedder} = ?1
|
||||
ORDER BY i.id",
|
||||
embedder = dr_catalog::faces::embedder_sql("f.model_id"),
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map([model_id], |r| {
|
||||
.query_map([dr_catalog::faces::embedder_of(model_id)], |r| {
|
||||
Ok(ThumbnailRequest {
|
||||
full_resolution: true,
|
||||
thumb_size: dr_thumbs::ThumbSize::Large,
|
||||
@@ -3902,11 +3905,66 @@ fn faces_unmeasured(
|
||||
JOIN remote r ON r.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 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"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map([model_id], |r| {
|
||||
.query_map(rusqlite::params_from_iter(weaker.iter()), |r| {
|
||||
Ok(ThumbnailRequest {
|
||||
full_resolution: true,
|
||||
thumb_size: dr_thumbs::ThumbSize::Large,
|
||||
@@ -3981,6 +4039,7 @@ pub fn spawn_face_sweep(
|
||||
detector_model: PathBuf,
|
||||
embedder_model: PathBuf,
|
||||
model_id: String,
|
||||
supersedes: Vec<String>,
|
||||
options: dr_face::DetectOptions,
|
||||
gpu: dr_gpu::GpuContext,
|
||||
) -> 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();
|
||||
if total == 0 {
|
||||
log::info!("face sweep: every image has been through this model");
|
||||
finish_empty(&tx);
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
@@ -6739,6 +6822,36 @@ mod tests {
|
||||
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
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user