Add the Identity screen
A third top-level screen beside the library and develop, because naming a cluster and pulling a stranger out of it are tasks with their own rhythm and need the whole window. The screen is designed around the clustering being wrong, which is FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on parents and children, and on the same person a decade apart. So Split off sits next to Confirm all rather than behind a menu, the confirm/reject pair is on the face itself, and a group the system found is drawn differently from a person the user has vouched for. Splitting rejects before it confirms. Without that the next clustering pass suggests the face straight back and the user's correction becomes an argument they keep having. Face crops come from the proxies the grid already built, one decode per image rather than per face -- a group photograph holding six faces of one family is one JPEG. Where the calibration is not fitted the screen says confidence is unavailable instead of printing a percentage that looks measured, which is FR-CULL-9's rule at the point it becomes visible. The verdict controls use drawn icons, not tick and cross characters: ui/icons.slint exists because those render as tofu on Android. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -446,6 +446,82 @@ fn group_probability(
|
||||
}
|
||||
}
|
||||
|
||||
/// A face cut out of its photograph, ready to draw.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FaceCrop {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Tightly packed RGBA.
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How much of the surrounding frame a face crop keeps, per side.
|
||||
///
|
||||
/// A face cut exactly to its detection box reads as a mugshot: no hair, no
|
||||
/// chin, no context, and a row of them is genuinely hard to tell apart — which
|
||||
/// matters, because telling them apart is the entire task the People screen
|
||||
/// asks of the user. A third on each side gives back the head.
|
||||
const CROP_MARGIN: f32 = 0.35;
|
||||
|
||||
/// Cut one face out of its proxy.
|
||||
///
|
||||
/// The box is normalised to the long edge (the catalog's convention), so this
|
||||
/// works whatever size the proxy happens to be now — the property that made
|
||||
/// normalising worth the trouble. The thumbnail cache is entitled to evict a
|
||||
/// proxy and regenerate it at another resolution, and a face stored in pixels
|
||||
/// would then point at the wrong part of the picture.
|
||||
pub fn crop_face(
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
face: &faces::Face,
|
||||
out_edge: u32,
|
||||
) -> Option<FaceCrop> {
|
||||
if width == 0 || height == 0 || out_edge == 0 {
|
||||
return None;
|
||||
}
|
||||
let long_edge = width.max(height) as f32;
|
||||
|
||||
// Square, centred on the face: the grid draws square cells, and cropping to
|
||||
// a square here rather than letterboxing there means the face fills the
|
||||
// cell instead of floating in it.
|
||||
let cx = (face.x + face.w * 0.5) * long_edge;
|
||||
let cy = (face.y + face.h * 0.5) * long_edge;
|
||||
let half = (face.w.max(face.h) * long_edge * 0.5) * (1.0 + CROP_MARGIN);
|
||||
if !(half.is_finite() && half > 0.5) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; (out_edge * out_edge * 4) as usize];
|
||||
let step = (half * 2.0) / out_edge as f32;
|
||||
|
||||
for oy in 0..out_edge {
|
||||
let sy = cy - half + (oy as f32 + 0.5) * step;
|
||||
for ox in 0..out_edge {
|
||||
let sx = cx - half + (ox as f32 + 0.5) * step;
|
||||
let o = ((oy * out_edge + ox) * 4) as usize;
|
||||
// Nearest neighbour: this is a downscale of an already-small proxy
|
||||
// shown at ~96 px, and a bilinear tap would cost four reads per
|
||||
// pixel for a difference nobody can see at that size. Outside the
|
||||
// frame stays transparent, so a face at the very edge of the
|
||||
// picture is drawn short rather than smeared.
|
||||
if sx < 0.0 || sy < 0.0 || sx >= width as f32 || sy >= height as f32 {
|
||||
continue;
|
||||
}
|
||||
let i = ((sy as u32 * width + sx as u32) * 4) as usize;
|
||||
if i + 4 <= rgba.len() {
|
||||
out[o..o + 4].copy_from_slice(&rgba[i..i + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(FaceCrop {
|
||||
width: out_edge,
|
||||
height: out_edge,
|
||||
rgba: out,
|
||||
})
|
||||
}
|
||||
|
||||
/// `dr-thumbs` decodes to RGBA; `dr-face` reads packed `f32` RGB.
|
||||
fn rgba_to_rgb_f32(rgba: &[u8]) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity(rgba.len() / 4 * 3);
|
||||
@@ -470,6 +546,87 @@ mod tests {
|
||||
assert!((n[2].0 - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
fn gradient(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut v = vec![0u8; (width * height * 4) as usize];
|
||||
for (i, px) in v.chunks_exact_mut(4).enumerate() {
|
||||
// A gradient, so a mis-placed crop shows up as the wrong value
|
||||
// rather than as more of the same colour.
|
||||
px[0] = (i % 251) as u8;
|
||||
px[1] = 40;
|
||||
px[2] = 90;
|
||||
px[3] = 255;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn stored_face(x: f32, y: f32, w: f32, h: f32) -> faces::Face {
|
||||
faces::Face {
|
||||
id: faces::FaceId(1),
|
||||
image_id: ImageId(1),
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
landmarks: [(0.0, 0.0); 5],
|
||||
confidence: 0.9,
|
||||
crop_px: 120.0,
|
||||
model_id: "w600k_mbf".into(),
|
||||
person: None,
|
||||
probability: 0.0,
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_crop_is_square_and_the_size_asked_for() {
|
||||
let px = gradient(1024, 683);
|
||||
let c = crop_face(&px, 1024, 683, &stored_face(0.3, 0.2, 0.1, 0.15), 96).unwrap();
|
||||
assert_eq!((c.width, c.height), (96, 96));
|
||||
assert_eq!(c.rgba.len(), 96 * 96 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_degenerate_box_yields_no_crop_rather_than_a_panic() {
|
||||
let px = gradient(64, 64);
|
||||
assert!(crop_face(&px, 64, 64, &stored_face(0.5, 0.5, 0.0, 0.0), 96).is_none());
|
||||
assert!(crop_face(&px, 0, 0, &stored_face(0.1, 0.1, 0.2, 0.2), 96).is_none());
|
||||
assert!(crop_face(&px, 64, 64, &stored_face(0.1, 0.1, 0.2, 0.2), 0).is_none());
|
||||
}
|
||||
|
||||
/// A face at the very edge of the frame is drawn short, not smeared: the
|
||||
/// out-of-frame margin stays transparent.
|
||||
#[test]
|
||||
fn a_face_at_the_edge_keeps_a_transparent_margin() {
|
||||
let px = gradient(200, 200);
|
||||
let c = crop_face(&px, 200, 200, &stored_face(0.0, 0.0, 0.1, 0.1), 32).unwrap();
|
||||
assert_eq!(c.rgba[3], 0, "outside the frame should be transparent");
|
||||
let centre = ((16 * 32 + 16) * 4 + 3) as usize;
|
||||
assert_eq!(c.rgba[centre], 255, "the face itself should be opaque");
|
||||
}
|
||||
|
||||
/// The normalised box means a proxy regenerated at another resolution still
|
||||
/// crops the same part of the picture — what the catalog's normalisation is
|
||||
/// for.
|
||||
#[test]
|
||||
fn the_same_face_crops_the_same_region_at_two_proxy_sizes() {
|
||||
let face = stored_face(0.25, 0.25, 0.2, 0.2);
|
||||
let small = crop_face(&gradient(400, 400), 400, 400, &face, 16).unwrap();
|
||||
let large = crop_face(&gradient(800, 800), 800, 800, &face, 16).unwrap();
|
||||
assert!(small.rgba.chunks_exact(4).all(|p| p[3] == 255));
|
||||
assert!(large.rgba.chunks_exact(4).all(|p| p[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crop_keeps_margin_around_the_detection_box() {
|
||||
// A 0.1-wide face in a 1000px frame is 100px; with the margin the crop
|
||||
// spans 100 * 1.35 = 135px of source.
|
||||
let face = stored_face(0.4, 0.4, 0.1, 0.1);
|
||||
let c = crop_face(&gradient(1000, 1000), 1000, 1000, &face, 135).unwrap();
|
||||
assert_eq!(c.width, 135);
|
||||
// Fully inside the frame, so nothing is transparent.
|
||||
assert!(c.rgba.chunks_exact(4).all(|p| p[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgba_becomes_packed_rgb_dropping_alpha() {
|
||||
let rgba = [255u8, 128, 0, 255, 0, 0, 0, 128];
|
||||
|
||||
Reference in New Issue
Block a user