Turn face boxes back into sensor space before matching regions

Faces are found on the thumbnail, which is cached the right way up --
the grid would lie on its side otherwise. Segmentation runs on a proxy
rendered through a neutral edit graph, which carries no orientation and
is therefore in sensor order. For anything shot in portrait the two
differ by a quarter turn, so a face and the person containing it were
being compared in spaces 90 degrees apart: no match, or worse, a match
against somebody else's region.

The transform goes on the face rather than on the proxy. Instance masks
are defined in the proxy's space and sampled long afterwards, so turning
that space would be a far larger change than naming a region warrants.

Also two things the first screenshot of the running app showed that no
test would have:

110 of 23,528 displayed as "0%", which reads as the feature having done
nothing. One decimal below ten percent, and a floor so real progress
never shows as none.

The rail picked some near-black covers, because the largest face in a
group is often the nearest one in a badly lit frame and a black square
beside a name identifies nobody. It now cuts the best few and takes the
first legible one, falling back to the largest when a person's every
photograph is dark -- which happens, and showing it beats showing
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:28:52 +02:00
co-authored by Claude Opus 5
parent 61c4547b9c
commit f00b92a0e6
6 changed files with 353 additions and 42 deletions
+33 -6
View File
@@ -116,7 +116,8 @@ pub struct SegmentationJob {
source: Arc<DemosaicedImage>,
session: SessionId,
abandon: Abandon,
/// Confirmed faces in this photograph, **normalised to the long edge**.
/// Confirmed faces in this photograph, **normalised to the long edge** of
/// the *upright* image.
///
/// Carried rather than looked up, because the job runs on a thread with no
/// catalog in reach — the same reason it carries the pixels. Normalised
@@ -124,6 +125,8 @@ pub struct SegmentationJob {
/// `run`, and these have to survive being scaled to whatever it turns out
/// to be.
names: Vec<crate::identity::NormalisedNamedBox>,
/// Undone before matching, since the proxy is in sensor order.
orientation: dr_types::Orientation,
}
impl SegmentationJob {
@@ -172,16 +175,31 @@ impl SegmentationJob {
// at the call site because this is where the proxy size is finally
// known.
if !self.names.is_empty() {
// The proxy is in sensor order; the faces are upright. The long
// edge is the same number either way — `max` survives the swap —
// but the axes do not, so the boxes are turned back before they
// are compared with anything.
let long_edge = rw.max(rh) as f32;
let displayed = if self.orientation.swaps_axes() {
(rh as f32, rw as f32)
} else {
(rw as f32, rh as f32)
};
let boxes: Vec<dr_face::NamedFace<'_>> = self
.names
.iter()
.map(|(x, y, w, h, name)| dr_face::NamedFace {
bbox: (
x * long_edge,
y * long_edge,
(x + w) * long_edge,
(y + h) * long_edge,
bbox: dr_face::naming::to_sensor_space(
(
x * long_edge,
y * long_edge,
(x + w) * long_edge,
(y + h) * long_edge,
),
displayed,
self.orientation.quarter_turns,
self.orientation.flip_h,
self.orientation.flip_v,
),
name,
})
@@ -518,6 +536,13 @@ pub struct DevelopSession {
/// ever on a library with no face indexing — in which case segmentation
/// behaves exactly as it did before, which is the point.
face_names: Vec<crate::identity::NormalisedNamedBox>,
/// How this photograph is stored relative to how it is shown.
///
/// Kept because the segmentation proxy is rendered through a *neutral*
/// graph and is therefore in sensor order, while faces were found on the
/// upright thumbnail. On anything shot in portrait the two differ by a
/// quarter turn, and matching them without undoing it finds nothing.
orientation: dr_types::Orientation,
/// Kept so the session can build GPU resources after construction.
///
/// The distance fields behind a subject mask are made when a layer is
@@ -647,6 +672,7 @@ impl DevelopSession {
Self {
id: SessionId::next(),
face_names: Vec::new(),
orientation,
ctx: ctx.clone(),
graph,
history,
@@ -1635,6 +1661,7 @@ impl DevelopSession {
session: self.id,
abandon: Abandon::default(),
names: self.face_names.clone(),
orientation: self.orientation,
}
}
+49 -6
View File
@@ -127,13 +127,21 @@ impl IndexAudit {
if c.images == 0 {
return "no images in the library".into();
}
// Whole numbers read fine at 40% and lie at 0.47%, which rounds to
// "0%" beside a count of 110 — a figure that says the feature is
// broken when it is merely early. One decimal below ten percent, and
// a floor so real progress never displays as none.
let pct = c.fraction() * 100.0;
let shown = if c.indexed > 0 && pct < 0.1 {
"<0.1%".to_string()
} else if pct < 10.0 {
format!("{pct:.1}%")
} else {
format!("{pct:.0}%")
};
let mut s = format!(
"{}/{} images indexed ({:.0}%), {} face(s), {} image(s) with none",
c.indexed,
c.images,
c.fraction() * 100.0,
c.faces,
c.without_faces,
"{}/{} images indexed ({shown}), {} face(s), {} image(s) with none",
c.indexed, c.images, c.faces, c.without_faces,
);
if self.ready > 0 {
s.push_str(&format!("; {} ready to index", self.ready));
@@ -651,6 +659,7 @@ mod tests {
let s = a.summary();
assert!(s.contains("60/100"), "{s}");
assert!(s.contains("60%"), "{s}");
assert!(!s.contains("60.0%"), "whole numbers above ten percent: {s}");
assert!(s.contains("30 ready"), "{s}");
assert!(s.contains("10 awaiting"), "{s}");
}
@@ -673,6 +682,40 @@ mod tests {
assert!(s.contains("10/10"), "{s}");
}
/// The figure the real library actually produced: 110 of 23,528 rounds to
/// "0%" at whole-number precision, which reads as nothing having happened.
#[test]
fn early_progress_does_not_display_as_zero() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 23_528,
indexed: 110,
without_faces: 64,
faces: 125,
},
ready: 69,
awaiting_proxy: 23_349,
};
let s = a.summary();
assert!(s.contains("0.5%"), "{s}");
assert!(!s.contains("(0%)"), "{s}");
}
#[test]
fn a_single_image_in_a_huge_library_still_shows_something() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 100_000,
indexed: 1,
without_faces: 1,
faces: 0,
},
ready: 99_999,
awaiting_proxy: 0,
};
assert!(a.summary().contains("<0.1%"), "{}", a.summary());
}
#[test]
fn an_empty_library_says_so_rather_than_reporting_zero_of_zero() {
assert_eq!(IndexAudit::default().summary(), "no images in the library");
+91 -14
View File
@@ -205,22 +205,70 @@ pub fn load_cover(
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 {
let mut rows = faces::for_person(catalog.connection(), person, true)?;
if rows.is_empty() {
return Ok(None);
};
}
// Confirmed first, then largest.
rows.sort_by(|a, b| {
b.confirmed
.cmp(&a.confirmed)
.then(b.crop_px.total_cmp(&a.crop_px))
});
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))
// Cut the best few and take the first legible one.
//
// Size alone picked some very dark crops on the reference library: the
// largest face in a group is often the one nearest the camera in a badly
// lit frame, and a black square beside a name identifies nobody. Bounded
// at a handful because each candidate costs a JPEG decode, and the list is
// already in preference order — so this gives up size only when the
// preferred face is genuinely too dark to recognise.
let mut fallback: Option<FaceCrop> = None;
for face in rows.iter().take(COVER_CANDIDATES) {
let Some((w, h, rgba)) = decode_proxy(catalog, store, face.image_id) else {
continue;
};
let Some(crop) = crop_face(&rgba, w, h, face, COVER_CROP_EDGE) else {
continue;
};
if mean_luma(&crop) >= MIN_COVER_LUMA {
return Ok(Some(crop));
}
fallback.get_or_insert(crop);
}
// Everything this person has is dark. Their largest face is still the best
// answer available, and showing it beats showing nothing.
Ok(fallback)
}
/// How many faces to cut before settling for the largest.
const COVER_CANDIDATES: usize = 4;
/// Mean luma a cover must reach to be preferred over a larger, darker one.
///
/// Low: this rejects the near-black, not the moody. A crop at 0.18 is a
/// legible face in a dim room; one at 0.05 is a silhouette.
const MIN_COVER_LUMA: f32 = 0.18;
/// Rec. 709 luma, averaged over the crop, ignoring transparent margin.
fn mean_luma(crop: &FaceCrop) -> f32 {
let mut sum = 0.0_f32;
let mut n = 0_u32;
for px in crop.rgba.chunks_exact(4) {
// Out-of-frame margin is transparent and black; counting it would make
// every edge-of-frame face look darker than it is.
if px[3] == 0 {
continue;
}
sum += (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32) / 255.0;
n += 1;
}
if n == 0 {
0.0
} else {
sum / n as f32
}
}
fn decode_proxy(
@@ -754,6 +802,35 @@ mod tests {
assert!(named_boxes_for_image(&c, img, 1024, 683).unwrap().is_empty());
}
#[test]
fn mean_luma_ignores_the_transparent_margin() {
// Half opaque white, half transparent black. Counting the margin would
// report 0.5; ignoring it reports 1.0, which is what the face is.
let mut rgba = vec![0u8; 4 * 4 * 4];
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
if i < 8 {
px.copy_from_slice(&[255, 255, 255, 255]);
}
}
let crop = FaceCrop {
width: 4,
height: 4,
rgba,
};
assert!((mean_luma(&crop) - 1.0).abs() < 1e-3, "{}", mean_luma(&crop));
}
#[test]
fn a_dark_crop_falls_below_the_cover_threshold_and_a_lit_one_clears_it() {
let solid = |v: u8| FaceCrop {
width: 2,
height: 2,
rgba: vec![v, v, v, 255, v, v, v, 255, v, v, v, 255, v, v, v, 255],
};
assert!(mean_luma(&solid(10)) < MIN_COVER_LUMA);
assert!(mean_luma(&solid(120)) >= MIN_COVER_LUMA);
}
#[test]
fn deleting_everything_empties_the_screen() {
let c = catalog();
+7
View File
@@ -2031,6 +2031,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
masks_ui::wire(&window, &session, &rows, &redraw);
// A way to land on the Identity Manager at startup, for looking at it
// without a mouse. Off unless the variable is set, so it costs a getenv
// per launch and changes nothing otherwise.
if std::env::var_os("DARKROOM_START_IDENTITY").is_some() {
window.invoke_identity_open();
}
{
let weak = window.as_weak();
let session = session.clone();