face_verification_core 0.2.0

Cross-platform on-device face liveness and verification core.
Documentation
use crate::{
    ChallengeStep, FrameAnalysis, Landmarks, LivenessChallenge, Point, PoseCheck, TouchTarget,
    VerificationThresholds,
};

pub fn validate_pose(
    step: &ChallengeStep,
    frame: &FrameAnalysis,
    _challenge: &LivenessChallenge,
    thresholds: &VerificationThresholds,
) -> PoseCheck {
    if !frame.face.detected {
        return PoseCheck::fail("No se detectó tu cara.");
    }

    let centered = validate_face_centered(frame);
    if !centered.valid {
        return centered;
    }

    match step {
        ChallengeStep::FaceCentered => PoseCheck::ok(),
        ChallengeStep::Smile => validate_smile(frame, thresholds),
        ChallengeStep::TurnLeftOnScreen => validate_yaw(frame, thresholds.yaw_min, true),
        ChallengeStep::TurnRightOnScreen => validate_yaw(frame, thresholds.yaw_min, false),
        ChallengeStep::ShowFingers { count } => validate_fingers(frame, *count, thresholds),
        ChallengeStep::TouchTarget { target } => validate_touch(frame, *target, thresholds),
    }
}

fn validate_face_centered(frame: &FrameAnalysis) -> PoseCheck {
    let Some(b) = frame.face.bounding_box else {
        return PoseCheck::fail("Centra tu cara en el óvalo.");
    };
    let cx = b.x + b.width / 2.0;
    let cy = b.y + b.height / 2.0;
    let dx = (cx - 0.5).abs();
    let dy = (cy - 0.45).abs();
    if dx > 0.18 || dy > 0.20 || b.width < 0.20 || b.width > 0.75 {
        return PoseCheck::fail("Centra tu cara en el óvalo.");
    }
    PoseCheck::ok()
}

fn validate_smile(frame: &FrameAnalysis, thresholds: &VerificationThresholds) -> PoseCheck {
    let smile = frame.face.smile_score.unwrap_or(0.0);
    if smile < thresholds.smile_min {
        return PoseCheck::fail("Sonríe más.");
    }
    PoseCheck::ok()
}

fn validate_yaw(frame: &FrameAnalysis, yaw_min: f32, left_on_screen: bool) -> PoseCheck {
    let Some(yaw) = compute_yaw(frame.face.landmarks.as_ref()) else {
        return PoseCheck::fail("Gira más hacia la flecha.");
    };
    let ok = if left_on_screen {
        yaw > yaw_min
    } else {
        yaw < -yaw_min
    };
    if ok {
        PoseCheck::ok()
    } else {
        PoseCheck::fail("Gira más hacia la flecha.")
    }
}

fn validate_fingers(
    frame: &FrameAnalysis,
    target: u8,
    thresholds: &VerificationThresholds,
) -> PoseCheck {
    if frame.hands.is_empty() {
        return PoseCheck::fail("No detectamos tu mano en la foto.");
    }
    let ok = frame
        .hands
        .iter()
        .filter_map(|h| h.extended_fingers)
        .any(|count| count.abs_diff(target) <= thresholds.fingers_tolerance);
    if ok {
        PoseCheck::ok()
    } else {
        PoseCheck::fail(format!(
            "Muestra {target} {}.",
            if target == 1 { "dedo" } else { "dedos" }
        ))
    }
}

fn validate_touch(
    frame: &FrameAnalysis,
    target: TouchTarget,
    thresholds: &VerificationThresholds,
) -> PoseCheck {
    if frame.hands.is_empty() {
        return PoseCheck::fail("No detectamos tu mano en la foto.");
    }
    let Some(landmarks) = frame.face.landmarks.as_ref() else {
        return PoseCheck::fail("No encontramos la zona objetivo.");
    };
    let targets = face_target_points(landmarks, target);
    if targets.is_empty() {
        return PoseCheck::fail("No encontramos la zona objetivo.");
    }
    let Some(face_width) = face_width_from_landmarks(landmarks) else {
        return PoseCheck::fail("No encontramos la zona objetivo.");
    };
    let min_distance = frame
        .hands
        .iter()
        .flat_map(|h| h.finger_tips.iter().copied())
        .flat_map(|tip| {
            targets
                .iter()
                .copied()
                .map(move |target| tip.distance_to(target))
        })
        .fold(f32::INFINITY, f32::min);

    if !min_distance.is_finite() || min_distance / face_width > thresholds.touch_target_max_distance
    {
        return PoseCheck::fail(target.touch_message());
    }
    PoseCheck::ok()
}

pub fn compute_yaw(landmarks: Option<&Landmarks>) -> Option<f32> {
    let landmarks = landmarks?;
    let nose = landmarks.get(30)?;
    let left = landmarks.get(0)?;
    let right = landmarks.get(16)?;
    let d_l = nose.distance_to(left);
    let d_r = nose.distance_to(right);
    let total = d_l + d_r;
    if total <= f32::EPSILON {
        None
    } else {
        Some((d_l - d_r) / total)
    }
}

fn face_width_from_landmarks(landmarks: &Landmarks) -> Option<f32> {
    let left = landmarks.get(0)?;
    let right = landmarks.get(16)?;
    Some(left.distance_to(right).max(1.0))
}

fn face_target_points(landmarks: &Landmarks, target: TouchTarget) -> Vec<Point> {
    match target {
        TouchTarget::Nose => landmarks.get(30).into_iter().collect(),
        TouchTarget::Ear => [landmarks.get(0), landmarks.get(16)]
            .into_iter()
            .flatten()
            .collect(),
        TouchTarget::Cheek => [landmarks.get(2), landmarks.get(14)]
            .into_iter()
            .flatten()
            .collect(),
    }
}