docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
264 lines
9.9 KiB
Rust
264 lines
9.9 KiB
Rust
//! TRACES: FR-CULL-8a
|
||
//! What a face's eyes are doing, and how the numbers behind it are read.
|
||
//!
|
||
//! Model-free: the models in [`crate::classify`] produce the numbers, and
|
||
//! everything that interprets them — the catalog's filter, the People
|
||
//! screen's label — comes through here, so a threshold lives in exactly one
|
||
//! place.
|
||
//!
|
||
//! # Seven numbers, one answer
|
||
//!
|
||
//! An eye classifier answers "open or closed" for whatever it is shown, and
|
||
//! it is shown three things it cannot answer for. **Dark glass**: over
|
||
//! sunglasses it answers anyway, confidently, for a state that cannot be
|
||
//! seen — so the reading carries P(sunglasses) from a classifier that looks
|
||
//! at the whole head, and that takes precedence. **A smear**: a soft eye is
|
||
//! not a closed one, but shown a blur the classifier says "closed" with the
|
||
//! same confidence it says anything, and on the reference library that was
|
||
//! the commonest wrong answer of all — small faces, motion, a proxy where
|
||
//! the native render should have been. So each eye carries how many source
|
||
//! pixels it spanned and how sharp the patch was, and an eye under either
|
||
//! floor is not asked. **A cheek**: a head turned far enough hides its far
|
||
//! eye, and the landmark contour of a hidden eye collapses to a sliver; an
|
||
//! eye much narrower than its partner is not asked either.
|
||
//!
|
||
//! The two eyes are kept apart rather than averaged. A wink is one eye
|
||
//! closed, and averaging it lands at 0.5 — the one value that says the least.
|
||
//! [`EyeState::Open`] requires every eye that *could be read* to be open;
|
||
//! a face with no readable eye is [`EyeState::Unreadable`], which is not a
|
||
//! blink and not open, and a filter for either leaves it alone.
|
||
|
||
/// One eye's numbers.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Eye {
|
||
/// P(open), the classifier's sigmoid.
|
||
pub open: f32,
|
||
/// Source pixels across the eye box — [`crate::align::EyePatch::source_px`].
|
||
pub px: f32,
|
||
/// [`crate::align::EyePatch::sharpness`] of the patch the classifier saw.
|
||
pub sharpness: f32,
|
||
}
|
||
|
||
/// The numbers the models produced for one face.
|
||
///
|
||
/// Stored per face, nullable as a whole: a face indexed before the eye models
|
||
/// existed, or on a device without them, has no reading rather than a
|
||
/// reading of zeros.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct EyeReading {
|
||
/// The subject's **right** eye — image-left.
|
||
pub right: Eye,
|
||
/// The subject's **left** eye — image-right.
|
||
pub left: Eye,
|
||
/// P(the head wears sunglasses).
|
||
pub sunglasses: f32,
|
||
}
|
||
|
||
/// Above this an eye is open. The classifier's own decision point; its
|
||
/// training put the two classes either side of a sigmoid and this is where
|
||
/// the sigmoid crosses.
|
||
pub const EYES_OPEN_THRESHOLD: f32 = 0.5;
|
||
|
||
/// Above this the head wears sunglasses and the eye readings are moot.
|
||
pub const SUNGLASSES_THRESHOLD: f32 = 0.5;
|
||
|
||
/// Fewest source pixels across an eye box for the eye to be read.
|
||
///
|
||
/// The classifier was trained on eyes down to about a dozen pixels wide
|
||
/// (its reference footage averaged 15–21); below that the 40-pixel patch is
|
||
/// an interpolation of nothing, and the answer is noise that reads as
|
||
/// "closed". docs/dev/faces.md §17.3 has the measurement behind the number.
|
||
pub const MIN_EYE_PX: f32 = 12.0;
|
||
|
||
/// Least [`Eye::sharpness`] for the eye to be read.
|
||
///
|
||
/// The same measure as the face's `min_sharpness`, over the eye patch, and
|
||
/// chosen the same way: the value under which the open-eyed faces of the
|
||
/// reference sample were being called closed. docs/dev/faces.md §17.3.
|
||
pub const MIN_EYE_SHARPNESS: f32 = 0.02;
|
||
|
||
/// An eye narrower than this fraction of its partner is the far eye of a
|
||
/// turned head, out of view behind the nose, and is not read.
|
||
///
|
||
/// A landmark model's contour for a hidden eye collapses towards the nose.
|
||
/// Measured on twenty native renders of the reference library
|
||
/// (docs/dev/faces.md §17.4): profiles put the far eye at 0.02–0.43 of the near
|
||
/// one, two three-quarter faces whose far eye read closed sat at 0.54, and
|
||
/// every face looking at the camera — winks included, since a shut eye's
|
||
/// box keeps its width — sat at 0.78 or more. 0.6 splits the gap.
|
||
pub const HIDDEN_EYE_RATIO: f32 = 0.6;
|
||
|
||
/// What the reading says, for a screen or a filter.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum EyeState {
|
||
/// Every eye that could be read is open.
|
||
Open,
|
||
/// An eye that could be read is closed — a blink, or a wink.
|
||
Closed,
|
||
/// The eyes cannot be seen. Neither open nor closed, and a filter for
|
||
/// either leaves the face alone.
|
||
Sunglasses,
|
||
/// No eye was sharp enough, large enough and in view to read. Neither
|
||
/// open nor closed, like sunglasses, and left alone by every filter.
|
||
Unreadable,
|
||
}
|
||
|
||
impl Eye {
|
||
/// Whether this eye can be read at all: enough pixels, sharp enough,
|
||
/// and not the collapsed contour of a hidden eye — measured against
|
||
/// `other`, its partner.
|
||
pub fn readable(&self, other: &Eye) -> bool {
|
||
self.px >= MIN_EYE_PX
|
||
&& self.sharpness >= MIN_EYE_SHARPNESS
|
||
&& self.px >= other.px * HIDDEN_EYE_RATIO
|
||
}
|
||
}
|
||
|
||
impl EyeReading {
|
||
pub fn state(&self) -> EyeState {
|
||
if self.sunglasses >= SUNGLASSES_THRESHOLD {
|
||
return EyeState::Sunglasses;
|
||
}
|
||
let readable = [
|
||
self.right.readable(&self.left).then_some(self.right.open),
|
||
self.left.readable(&self.right).then_some(self.left.open),
|
||
];
|
||
let mut any = false;
|
||
for open in readable.into_iter().flatten() {
|
||
any = true;
|
||
if open < EYES_OPEN_THRESHOLD {
|
||
return EyeState::Closed;
|
||
}
|
||
}
|
||
if any {
|
||
EyeState::Open
|
||
} else {
|
||
EyeState::Unreadable
|
||
}
|
||
}
|
||
|
||
/// Whether this is a face a "no one blinking" filter should drop.
|
||
///
|
||
/// The filter's question, rather than [`EyeState`]'s four-way answer,
|
||
/// because the two differ on exactly the cases that matter: a face
|
||
/// behind sunglasses, or one whose eyes could not be read, is not open
|
||
/// — and it is not a blink either. Only [`EyeState::Closed`] is one.
|
||
pub fn is_blink(&self) -> bool {
|
||
self.state() == EyeState::Closed
|
||
}
|
||
}
|
||
|
||
impl EyeState {
|
||
/// The word the People screen puts on the face.
|
||
pub fn label(&self) -> &'static str {
|
||
match self {
|
||
EyeState::Open => "Eyes open",
|
||
EyeState::Closed => "Eyes closed",
|
||
EyeState::Sunglasses => "Sunglasses",
|
||
EyeState::Unreadable => "Eyes unclear",
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn eye(open: f32) -> Eye {
|
||
Eye {
|
||
open,
|
||
px: 40.0,
|
||
sharpness: 0.1,
|
||
}
|
||
}
|
||
|
||
fn reading(right: f32, left: f32, sunglasses: f32) -> EyeReading {
|
||
EyeReading {
|
||
right: eye(right),
|
||
left: eye(left),
|
||
sunglasses,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn both_eyes_open_is_open() {
|
||
assert_eq!(reading(0.9, 0.8, 0.1).state(), EyeState::Open);
|
||
assert!(!reading(0.9, 0.8, 0.1).is_blink());
|
||
}
|
||
|
||
/// A wink is not "eyes open": one eye closed lands the same place a
|
||
/// blink does, and a filter for "nobody blinking" should drop it.
|
||
#[test]
|
||
fn one_eye_closed_is_closed() {
|
||
assert_eq!(reading(0.9, 0.2, 0.1).state(), EyeState::Closed);
|
||
assert_eq!(reading(0.2, 0.9, 0.1).state(), EyeState::Closed);
|
||
assert!(reading(0.2, 0.9, 0.1).is_blink());
|
||
}
|
||
|
||
/// The whole reason the sunglasses number exists: whatever the eye
|
||
/// classifier says over dark glass, it is not a reading of the eyes.
|
||
#[test]
|
||
fn sunglasses_override_the_eye_readings_either_way() {
|
||
assert_eq!(reading(0.9, 0.9, 0.8).state(), EyeState::Sunglasses);
|
||
assert_eq!(reading(0.1, 0.1, 0.8).state(), EyeState::Sunglasses);
|
||
assert!(!reading(0.1, 0.1, 0.8).is_blink());
|
||
}
|
||
|
||
/// A soft or tiny eye is not asked; if neither can be, the face is
|
||
/// unreadable rather than closed.
|
||
#[test]
|
||
fn a_soft_or_tiny_eye_is_not_read() {
|
||
let mut r = reading(0.1, 0.9, 0.0);
|
||
r.right.sharpness = MIN_EYE_SHARPNESS / 2.0;
|
||
assert_eq!(r.state(), EyeState::Open, "the soft closed eye is ignored");
|
||
|
||
let mut r = reading(0.1, 0.9, 0.0);
|
||
r.right.px = MIN_EYE_PX - 1.0;
|
||
assert_eq!(r.state(), EyeState::Open, "the tiny closed eye is ignored");
|
||
|
||
let mut r = reading(0.1, 0.1, 0.0);
|
||
r.right.sharpness = 0.0;
|
||
r.left.px = 3.0;
|
||
assert_eq!(r.state(), EyeState::Unreadable);
|
||
assert!(!r.is_blink());
|
||
assert_eq!(r.state().label(), "Eyes unclear");
|
||
}
|
||
|
||
/// A profile: the far eye's contour collapses, and the sliver is not
|
||
/// read. The near eye still decides.
|
||
#[test]
|
||
fn a_turned_heads_collapsed_far_eye_is_not_read() {
|
||
let mut r = reading(0.05, 0.95, 0.0);
|
||
r.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0;
|
||
assert!(!r.right.readable(&r.left));
|
||
assert_eq!(r.state(), EyeState::Open);
|
||
|
||
let mut blink = reading(0.95, 0.05, 0.0);
|
||
blink.right.px = 40.0 * HIDDEN_EYE_RATIO - 1.0;
|
||
assert_eq!(blink.state(), EyeState::Closed);
|
||
|
||
// Both eyes narrow but alike is not a turned head: both count.
|
||
let mut small = reading(0.05, 0.95, 0.0);
|
||
small.right.px = 14.0;
|
||
small.left.px = 14.0;
|
||
assert_eq!(small.state(), EyeState::Closed);
|
||
}
|
||
|
||
#[test]
|
||
fn the_thresholds_are_inclusive_at_the_decision_point() {
|
||
assert_eq!(
|
||
reading(EYES_OPEN_THRESHOLD, EYES_OPEN_THRESHOLD, 0.0).state(),
|
||
EyeState::Open
|
||
);
|
||
assert_eq!(
|
||
reading(1.0, 1.0, SUNGLASSES_THRESHOLD).state(),
|
||
EyeState::Sunglasses
|
||
);
|
||
let mut r = reading(1.0, 1.0, 0.0);
|
||
r.right.px = MIN_EYE_PX;
|
||
r.left.px = MIN_EYE_PX;
|
||
r.right.sharpness = MIN_EYE_SHARPNESS;
|
||
assert!(r.right.readable(&r.left));
|
||
}
|
||
}
|