Show a face beside each name in the people rail

The rail was drawing an empty square for every person: cover was
hardcoded to a default image. That is the one place a portrait matters
most, because the rail is how the user decides which unnamed group to
open first, and a list of "Unnamed (24 faces)" rows tells them nothing.

The portrait is the person's confirmed face with the largest crop_px --
the most source pixels the face actually occupied, so the one they have
the best chance of recognising -- falling back to a suggestion so a
freshly clustered group still has a face beside it.

Cached in the controller, because every mutating action reloads the whole
screen and cutting a portrait costs a JPEG decode per person. Without the
cache, confirming one face would re-decode a proxy for every person in
the library, and the rail does not change when a suggestion is accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:52:38 +02:00
co-authored by Claude Opus 5
parent 2944c1b704
commit d562ceaaf4
2 changed files with 74 additions and 8 deletions
+38
View File
@@ -34,6 +34,13 @@ use crate::faces::{crop_face, FaceCrop, FACE_TIER};
/// the same person* — is one the user cannot make from a postage stamp. /// the same person* — is one the user cannot make from a postage stamp.
pub const FACE_CROP_EDGE: u32 = 128; pub const FACE_CROP_EDGE: u32 = 128;
/// Edge of the portrait beside a name in the people rail.
///
/// Small on purpose: the rail is for *recognising* a person you already know,
/// not for judging a likeness, and forty of them at grid size would be a
/// second grid competing with the real one.
pub const COVER_CROP_EDGE: u32 = 80;
/// A row in the people rail. /// A row in the people rail.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PersonRow { pub struct PersonRow {
@@ -185,6 +192,37 @@ pub fn load_faces(
Ok(out) Ok(out)
} }
/// The face to show as a person's portrait.
///
/// Confirmed faces first, then the largest — `crop_px` is the number of source
/// pixels the face actually occupied, so the largest is the one the user has
/// the best chance of recognising. Falling back to a suggestion means a
/// freshly clustered group still has a face beside it, which is the moment the
/// portrait matters most: the rail is how the user decides which unnamed group
/// to open first.
pub fn load_cover(
catalog: &Catalog,
store: &ThumbStore,
person: PersonId,
) -> Result<Option<FaceCrop>, dr_catalog::CatalogError> {
let rows = faces::for_person(catalog.connection(), person, true)?;
let Some(best) = rows
.into_iter()
.max_by(|a, b| {
a.confirmed
.cmp(&b.confirmed)
.then(a.crop_px.total_cmp(&b.crop_px))
})
else {
return Ok(None);
};
let Some((w, h, rgba)) = decode_proxy(catalog, store, best.image_id) else {
return Ok(None);
};
Ok(crop_face(&rgba, w, h, &best, COVER_CROP_EDGE))
}
fn decode_proxy( fn decode_proxy(
catalog: &Catalog, catalog: &Catalog,
store: &ThumbStore, store: &ThumbStore,
+36 -8
View File
@@ -43,6 +43,13 @@ pub struct IdentityController {
/// Images visited so far in the current sweep, and the total it announced. /// Images visited so far in the current sweep, and the total it announced.
progress: std::cell::Cell<(usize, usize)>, progress: std::cell::Cell<(usize, usize)>,
faces_found: std::cell::Cell<usize>, faces_found: std::cell::Cell<usize>,
/// Rail portraits, kept between refreshes.
///
/// Every mutating action reloads the whole screen, and cutting a portrait
/// costs a JPEG decode per person. Without this, confirming one face would
/// re-decode a proxy for every person in the library — and the rail does
/// not change when a suggestion is accepted.
covers: RefCell<std::collections::HashMap<PersonId, slint::Image>>,
} }
impl IdentityController { impl IdentityController {
@@ -85,17 +92,37 @@ pub fn refresh(
window.set_identity_unassigned(view.unassigned as i32); window.set_identity_unassigned(view.unassigned as i32);
window.set_identity_calibrated(view.calibrated); window.set_identity_calibrated(view.calibrated);
// Drop portraits for people who no longer exist, so a long session that
// merges and splits repeatedly does not accumulate them.
{
let live: std::collections::HashSet<PersonId> =
view.people.iter().map(|p| p.id).collect();
ctl.covers.borrow_mut().retain(|id, _| live.contains(id));
}
let rows: Vec<IdentityPerson> = view let rows: Vec<IdentityPerson> = view
.people .people
.iter() .iter()
.map(|p| IdentityPerson { .map(|p| {
id: p.id.0 as i32, let cover = store.and_then(|store| {
label: p.display_name().into(), if let Some(img) = ctl.covers.borrow().get(&p.id) {
unconfirmed: p.is_unconfirmed(), return Some(img.clone());
confirmed_faces: p.confirmed_faces as i32, }
suggested_faces: p.suggested_faces as i32, let crop = identity::load_cover(cat, store, p.id).ok().flatten()?;
cover: slint::Image::default(), let img = to_slint_image(crop.width, crop.height, &crop.rgba);
has_cover: false, ctl.covers.borrow_mut().insert(p.id, img.clone());
Some(img)
});
IdentityPerson {
id: p.id.0 as i32,
label: p.display_name().into(),
unconfirmed: p.is_unconfirmed(),
confirmed_faces: p.confirmed_faces as i32,
suggested_faces: p.suggested_faces as i32,
has_cover: cover.is_some(),
cover: cover.unwrap_or_default(),
}
}) })
.collect(); .collect();
window.set_identity_people(ModelRc::new(VecModel::from(rows))); window.set_identity_people(ModelRc::new(VecModel::from(rows)));
@@ -549,6 +576,7 @@ pub fn wire<S, M, P>(
} }
ctl.selected.set(None); ctl.selected.set(None);
ctl.clear_picks(); ctl.clear_picks();
ctl.covers.borrow_mut().clear();
reload!(w, ctl, catalog, store); reload!(w, ctl, catalog, store);
}); });
} }