Make the People screen a place work can be done
Five faults, all on one screen, and the Slint and Rust halves of each have to land together. **Regroup froze the window.** It ran inside the Slint callback, on the UI thread. It is much faster now, but fast is not bounded — the work grows with the library, and the one thing that must not grow with the library is how long the window stops answering. It runs on a worker thread with an mpsc channel and a 250ms poll, like every other long pass in this module, and the button says what it is doing instead of the window going quiet. Cancellation is dropping the receiver. Reclustering also prunes the empty groups the previous pass left, so pressing the button twice no longer fills the rail with "Unnamed (0 faces)". **The faces were a single row running off the screen.** The comment on the layout claimed to be a wrapping row; Slint has no flow layout and a HorizontalLayout does not wrap, so a person with forty faces was a person whose faces could not be reviewed past the fifth. It is now laid out the way the library grid lays out thumbnails, with the same arithmetic: choose how many columns of roughly the requested size fit, then divide the width between them so the cells fill the row exactly and nothing overhangs. **The header did not fit a phone.** A 240px name field beside five buttons is wider than an Android screen — and worse than not fitting, a layout cannot be narrower than its children's minimums, so the row reported that oversized minimum upwards and inflated the whole screen. The faces grid is its sibling, so it would have been measured against a width that was never on the display. The header is now two rows, the actions sit in a Flickable that scrolls rather than overflowing, and the rail narrows to 132px on the compact class. **Strangers crowded out the people who matter.** Most clusters in a real library are passers-by and other people's guests. "Not interested" sets a group aside; the rail hides it and says how many are hidden, with one button to bring them back. Reversible, and never a deletion — see the catalog commit for why. **A face was a dead end.** Identifying someone and then having no way to see their photographs is a filing cabinet with no drawer handles. "Show photos" narrows the library grid to that person and leaves a chip on the filter bar saying so, which is also how it is cleared. It is a term on `RatingFilter` rather than a grid scope of its own, exactly as that struct's own doc says new narrowing terms should be — so the count and the cells are narrowed by the same thing, and it composes with the others for free. Suggested faces count, not only confirmed ones, or a freshly grouped person would show an empty grid. Crops are read from where they are now stored, falling back to cutting one out of the proxy for faces indexed before that existed. 480 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+147
-1
@@ -28,7 +28,7 @@ use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use dr_catalog::faces::{self, DetectedFace};
|
||||
use dr_catalog::Catalog;
|
||||
use dr_face::{align, Calibration, DetectOptions, Detector, Embedder, ModelId};
|
||||
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
|
||||
use dr_thumbs::{ThumbSize, ThumbStore};
|
||||
use dr_types::ImageId;
|
||||
|
||||
@@ -250,6 +250,9 @@ pub fn index_proxy(
|
||||
embedding: embedding.to_f16_bytes(),
|
||||
crop_px: aligned.source_px(),
|
||||
model_id: embedder.model().as_str().to_string(),
|
||||
// Cut here, while the buffer is still in hand. This is the only
|
||||
// moment in the whole pipeline where the pixels are free.
|
||||
crop: cut_crop(rgb, width, height, d).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -528,6 +531,16 @@ pub fn recluster(
|
||||
}
|
||||
}
|
||||
|
||||
// Groups the *previous* pass created that this one left empty. Without
|
||||
// this, every press of Regroup adds a rail entry per group it no longer
|
||||
// believes in, and the screen fills with "Unnamed (0 faces)" — which is
|
||||
// what made pressing the button twice look like it had broken something.
|
||||
match faces::prune_empty_unnamed(conn) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => log::info!("reclustering removed {n} empty group(s) from the previous pass"),
|
||||
Err(e) => log::warn!("pruning empty groups: {e}"),
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"reclustered {} face(s) into {} group(s): {suggested} suggestion(s), {created} new",
|
||||
candidates.len(),
|
||||
@@ -536,6 +549,71 @@ pub fn recluster(
|
||||
Ok((suggested, created))
|
||||
}
|
||||
|
||||
/// Progress from a regrouping pass.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReclusterMessage {
|
||||
/// How many faces went in. Sent once, before the arithmetic starts.
|
||||
Started { faces: usize },
|
||||
/// It finished.
|
||||
Finished { suggested: usize, created: usize },
|
||||
/// It did not.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Group the library's faces into people, **on a worker thread**.
|
||||
///
|
||||
/// The reason this exists rather than callers just invoking [`recluster`]: it
|
||||
/// used to run inside the Slint callback, on the UI thread, and clustering a
|
||||
/// real library is not something a callback can do. The window froze for as
|
||||
/// long as it took, with no progress, no cancel and no repaint — the button
|
||||
/// looked broken because from the outside it was indistinguishable from broken.
|
||||
///
|
||||
/// It is much faster now (see [`dr_face::cluster`]), but *fast* is not the same
|
||||
/// as *bounded*: the work grows with the library and the one thing that must
|
||||
/// not grow with the library is how long the window stops answering. So it runs
|
||||
/// where every other long pass in this module runs.
|
||||
///
|
||||
/// Cancellation is dropping the receiver, exactly as with the indexing sweep.
|
||||
/// Nothing is left half-written: [`recluster`] does its work in the catalog's
|
||||
/// own transactions, and a pass abandoned partway simply leaves the previous
|
||||
/// grouping in place to be redone.
|
||||
pub fn spawn_recluster(
|
||||
catalog_path: PathBuf,
|
||||
model_id: String,
|
||||
min_probability: f32,
|
||||
) -> Receiver<ReclusterMessage> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let catalog = match Catalog::open(&catalog_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx.send(ReclusterMessage::Failed(format!(
|
||||
"cannot open catalog: {e}"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Announced before the work so the screen can say what it is chewing
|
||||
// on. Cheap: it is a count, not the embeddings themselves.
|
||||
let count = faces::embeddings(catalog.connection(), &model_id)
|
||||
.map(|e| e.len())
|
||||
.unwrap_or(0);
|
||||
if tx.send(ReclusterMessage::Started { faces: count }).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let msg = match recluster(&catalog, &model_id, min_probability) {
|
||||
Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created },
|
||||
Err(e) => ReclusterMessage::Failed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(msg);
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// Mean calibrated probability between one member and the rest of its group.
|
||||
fn group_probability(
|
||||
candidates: &[dr_face::Candidate],
|
||||
@@ -643,6 +721,74 @@ pub fn crop_face(
|
||||
})
|
||||
}
|
||||
|
||||
/// Edge of the crop stored with a face.
|
||||
///
|
||||
/// Above both [`crate::identity::FACE_CROP_EDGE`] (128) and
|
||||
/// `COVER_CROP_EDGE` (80), so the stored image is downsampled to draw and never
|
||||
/// upsampled — a stored crop the same size as the grid cell would go soft the
|
||||
/// moment either constant grew. 160 px of JPEG is a few KB, which is nothing
|
||||
/// beside the 250 KB proxy it saves decoding.
|
||||
pub const STORED_CROP_EDGE: u32 = 160;
|
||||
|
||||
/// Cut one detected face out of the buffer it was found in, as a JPEG.
|
||||
///
|
||||
/// The same square [`crop_face`] would cut — centred on the box, widened by
|
||||
/// [`CROP_MARGIN`] — so a face drawn from its stored crop and one drawn the old
|
||||
/// way from the proxy are the same picture. Working in pixels rather than
|
||||
/// normalised coordinates because at this point in the pipeline that is what
|
||||
/// there is; the normalising happens afterwards.
|
||||
///
|
||||
/// **Out-of-frame samples clamp to the edge rather than going transparent.**
|
||||
/// [`crop_face`] leaves them clear, which is right when the caller can composite
|
||||
/// them; JPEG has no alpha, so the same choice here would bake a black bar into
|
||||
/// every face near the edge of its photograph — and then drag that face's mean
|
||||
/// luma down far enough for `load_cover` to reject it as too dark.
|
||||
///
|
||||
/// `None` where the geometry is degenerate, which is the caller's cue to store
|
||||
/// nothing and fall back to the proxy.
|
||||
fn cut_crop(rgb: &[f32], width: usize, height: usize, d: &Detection) -> Option<Vec<u8>> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let cx = d.bbox.0 + d.width() * 0.5;
|
||||
let cy = d.bbox.1 + d.height() * 0.5;
|
||||
let half = d.width().max(d.height()) * 0.5 * (1.0 + CROP_MARGIN);
|
||||
if !(half.is_finite() && half > 0.5 && cx.is_finite() && cy.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let edge = STORED_CROP_EDGE;
|
||||
let mut out = vec![0u8; (edge * edge * 4) as usize];
|
||||
let step = (half * 2.0) / edge as f32;
|
||||
|
||||
for oy in 0..edge {
|
||||
let sy = cy - half + (oy as f32 + 0.5) * step;
|
||||
let sy = (sy.max(0.0) as usize).min(height - 1);
|
||||
for ox in 0..edge {
|
||||
let sx = cx - half + (ox as f32 + 0.5) * step;
|
||||
let sx = (sx.max(0.0) as usize).min(width - 1);
|
||||
|
||||
let i = (sy * width + sx) * 3;
|
||||
let o = ((oy * edge + ox) * 4) as usize;
|
||||
if i + 3 > rgb.len() {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
out[o + c] = (rgb[i + c].clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
out[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
match dr_thumbs::encode_rgba(edge, edge, &out) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
log::debug!("encoding a face crop: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect and embed every face in a decoded preview.
|
||||
///
|
||||
/// The counterpart to [`index_proxy`] for the fetching sweep, which holds a
|
||||
|
||||
Reference in New Issue
Block a user