Keep the proxy a found face will be cropped from

Every cell on the People screen read "no preview" while the sweep was happily
reporting 893 faces found. Both were true. Faces were being detected and stored
correctly; there was simply nothing left to draw them from.

A face is stored normalised and drawn by cropping the proxy it was found on —
`identity::decode_proxy` reads `FACE_TIER` out of the thumbnail store. The
fetching sweep fetched a preview, detected on it, wrote the faces and dropped
the pixels. So every face it found pointed at a proxy that had never been
stored, and the grid had nothing to cut.

Worse, that state could not repair itself: the image has its `face_index` row,
so it is not outstanding work and no later pass would look at it again.

Two fixes, and the first is nearly free. The sweep now keeps the proxy — it has
already paid the round trip and the decode, and the crop needs those same pixels
the moment the user opens the person. Kept **only where a face was found**:
two thirds of a personal library is landscapes and documents (docs/faces.md
§7a), those will never be cropped, and skipping them keeps this well clear of
the whole-library cost `SWEEP_THUMB_SIZE` deliberately avoids. The downscale to
the large class happens after detection, which is the last use of the full
buffer.

Second, the sweep now picks up images that have faces with no proxy, whatever
put them in that state — this bug, or an ordinary cache eviction, which would
have produced exactly the same empty grid. Re-running detection repairs it and
loses nothing: `record_detections` replaces rather than appends and carries the
user's confirmations across the replacement. That makes the screen
self-healing rather than dependent on nobody ever evicting a thumbnail.

The proxy is stored *before* the detections. A kill between the two then leaves
a proxy with no faces — which the next pass simply re-indexes — rather than
faces with no proxy, which is the state that cannot recover.

Note for the library already part way through a sweep: the 986 images indexed
before this will be picked up by the repair route on the next run.

470 tests pass, including one that a face whose proxy is gone becomes work again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:35:12 +02:00
co-authored by Claude Opus 5
parent c8c6368542
commit 051447bda6
5 changed files with 571 additions and 39 deletions
+186 -9
View File
@@ -2682,6 +2682,65 @@ fn faces_unindexed(
Ok(rows)
}
/// One image the face sweep got through, on its way back from a fetch lane.
///
/// The catalog id, the faces found, the long edge they were normalised
/// against, and the proxy to keep — `None` where the image held no face, since
/// nothing will ever ask to crop one out of it.
type IndexedImage = (
i64,
Vec<dr_catalog::faces::DetectedFace>,
u32,
Option<(u64, dr_thumbs::Thumbnail)>,
);
/// Images whose faces have nothing left to be cut out of.
///
/// A face is stored normalised and drawn by cropping the proxy it was found on
/// (`identity::decode_proxy`). Where that proxy is gone the People screen draws
/// "no preview" for every cell and cannot repair itself, because the image
/// already has its `face_index` row and so is not outstanding work.
///
/// Two ways in: an indexing pass that fetched a preview and did not keep it,
/// and an ordinary cache eviction. Both are the same state, and re-running
/// detection over the image fixes it — `record_detections` replaces rather than
/// appends, and carries the user's confirmations across the replacement, so
/// this costs a fetch and loses nothing.
fn faces_without_proxy(
catalog: &Catalog,
store: &ThumbStore,
model_id: &str,
) -> Result<Vec<ThumbnailRequest>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT DISTINCT i.id, i.source_ref, r.file_id, i.file_size
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"
))?;
let rows = stmt
.query_map([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,
})
})?
.filter_map(Result::ok)
.filter(|req| {
req.file_id
.is_some_and(|id| !store.contains(id, dr_thumbs::ThumbSize::Large))
})
.collect();
Ok(rows)
}
/// TRACES: FR-CULL-8 | NFR-ARCH-2
/// Index faces across the **whole** library, fetching what it needs.
///
@@ -2721,6 +2780,7 @@ pub fn spawn_face_sweep(
creds: AppCredentials,
user_id: String,
catalog_path: PathBuf,
store_dir: PathBuf,
detector_model: PathBuf,
embedder_model: PathBuf,
model_id: String,
@@ -2747,6 +2807,23 @@ pub fn spawn_face_sweep(
}
};
// The proxy an indexed face is later *cut from*.
//
// `identity::decode_proxy` reads `FACE_TIER` out of this store to draw
// the People screen, so a face found on a preview that was fetched and
// dropped has nowhere to come from and the grid shows "no preview" for
// every cell. Fetching the pixels and not keeping them was the whole
// bug: they cost a round trip and a decode, and the crop needs them
// again the moment the user looks at the person.
let mut store = match ThumbStore::open(&store_dir) {
Ok(s) => s,
Err(e) => {
log::warn!("face sweep: cannot open the thumbnail store: {e}");
finish_empty(&tx);
return;
}
};
// Models before the work list: they are the expensive failure, and
// listing twenty thousand images before discovering the weights are
// missing helps nobody. A library with no model installed takes this
@@ -2771,7 +2848,7 @@ pub fn spawn_face_sweep(
}
};
let wanted = match faces_unindexed(&catalog, &model_id) {
let mut wanted = match faces_unindexed(&catalog, &model_id) {
Ok(w) => w,
Err(e) => {
log::warn!("face sweep: {e}");
@@ -2779,6 +2856,19 @@ pub fn spawn_face_sweep(
return;
}
};
// Disjoint from the above by construction: an image with faces recorded
// is not an image with no `face_index` row.
match faces_without_proxy(&catalog, &store, &model_id) {
Ok(repair) if !repair.is_empty() => {
log::info!(
"face sweep: {} image(s) have faces with no proxy to crop from",
repair.len()
);
wanted.extend(repair);
}
Ok(_) => {}
Err(e) => log::warn!("face sweep: looking for orphaned faces: {e}"),
}
let total = wanted.len();
if total == 0 {
@@ -2832,8 +2922,7 @@ pub fn spawn_face_sweep(
let models = &models;
let options = &options;
async move {
let mut indexed: Vec<(i64, Vec<dr_catalog::faces::DetectedFace>, u32)> =
Vec::new();
let mut indexed: Vec<IndexedImage> = Vec::new();
let mut discard = Vec::new();
let mut attempted = 0usize;
let mut failed = 0usize;
@@ -2841,14 +2930,43 @@ pub fn spawn_face_sweep(
for req in lane {
attempted += 1;
match fetch_preview(backend, req, &mut discard).await {
PreviewOutcome::Ready(preview) => {
PreviewOutcome::Ready(mut preview) => {
// No await inside this borrow — see the
// note where `models` is built.
let mut m = models.borrow_mut();
let (det, emb) = &mut *m;
match crate::faces::index_preview(det, emb, &preview, options) {
let found = {
let mut m = models.borrow_mut();
let (det, emb) = &mut *m;
crate::faces::index_preview(det, emb, &preview, options)
};
match found {
Ok((faces, edge)) => {
indexed.push((req.image_id, faces, edge))
// Keep the proxy only where there
// is a face to cut out of it. Two
// thirds of a personal library is
// landscapes and documents
// (docs/faces.md §7a), and those
// never need a crop — so this fills
// the large class for the images
// the People screen will actually
// ask about and leaves the rest
// alone, rather than paying the
// whole-library cost
// `SWEEP_THUMB_SIZE` avoids.
//
// Downscaled only now: detection
// needed the full buffer, and this
// is the last use of it.
let keep = match (faces.is_empty(), req.file_id) {
(false, Some(file_id)) => {
preview.downscale_to(
dr_thumbs::ThumbSize::Large.edge(),
);
encode_preview(file_id, &preview)
.map(|t| (file_id, t))
}
_ => None,
};
indexed.push((req.image_id, faces, edge, keep));
}
Err(e) => {
log::debug!("face sweep: {}: {e}", req.path);
@@ -2877,7 +2995,20 @@ pub fn spawn_face_sweep(
done += attempted;
failed += lane_failed;
offline |= lane_offline;
for (image_id, faces, edge) in indexed {
for (image_id, faces, edge, keep) in indexed {
// Before the detections, so a kill between the two
// leaves a proxy with no faces recorded — which the
// next pass simply re-indexes — rather than faces with
// no proxy, which is the state that draws an empty
// grid and cannot repair itself.
if let Some((file_id, thumb)) = keep {
store_thumbnail(
&mut store,
file_id,
dr_thumbs::ThumbSize::Large,
&thumb,
);
}
// Written per image, including the ones with no face in
// them: `face_index` records that detection *ran*, and
// zero is its most valuable value — without the row,
@@ -4417,6 +4548,52 @@ mod tests {
.collect()
}
/// A face with no proxy left draws "no preview" and cannot repair itself:
/// the image has its `face_index` row, so it is not outstanding work. The
/// sweep has to pick it up by a second route.
#[test]
fn a_face_whose_proxy_is_gone_is_work_again() {
let catalog = with_images(3);
let ids = image_ids(&catalog);
// An empty store, which is the state the bug lives in: the face is
// recorded and there is nothing on disk to cut it out of.
let store_dir =
std::env::temp_dir().join(format!("dr-face-proxy-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&store_dir);
std::fs::create_dir_all(&store_dir).unwrap();
let store = ThumbStore::open(&store_dir).unwrap();
let face = dr_catalog::faces::DetectedFace {
x: 0.1,
y: 0.1,
w: 0.2,
h: 0.2,
landmarks: [(0.0, 0.0); 5],
confidence: 0.9,
embedding: vec![0u8; 1024],
crop_px: 120.0,
model_id: "w600k_mbf".into(),
};
dr_catalog::faces::record_detections(
catalog.connection(),
ids[0],
"w600k_mbf",
1024,
std::slice::from_ref(&face),
)
.unwrap();
// Indexed, so not outstanding — but with nothing to crop from.
assert!(!faces_unindexed(&catalog, "w600k_mbf")
.unwrap()
.iter()
.any(|r| r.image_id == ids[0].0 as i64));
let repair = faces_without_proxy(&catalog, &store, "w600k_mbf").unwrap();
assert_eq!(repair.len(), 1, "the orphaned face was not picked up");
assert_eq!(repair[0].image_id, ids[0].0 as i64);
let _ = std::fs::remove_dir_all(&store_dir);
}
/// The regression this whole pass exists for.
///
/// The previous work list intersected with the thumbnail store, so a