face_verification_core 0.2.0

Cross-platform on-device face liveness and verification core.
Documentation
use serde::{Deserialize, Serialize};

use crate::{
    validate_pose, CapturedPhotoAnalysis, Embedding, FaceAnalysis, FaceVerificationError,
    LivenessChallenge, VerificationThresholds, EXPECTED_PHOTO_COUNT,
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VerificationChecks {
    pub all_have_face: bool,
    pub same_person: bool,
    pub poses_distinct: bool,
    pub no_duplicates: bool,
    pub pose_valid: bool,
    pub selfie_safe: bool,
    pub failed_index: Option<usize>,
}

impl Default for VerificationChecks {
    fn default() -> Self {
        Self {
            all_have_face: true,
            same_person: true,
            poses_distinct: true,
            no_duplicates: true,
            pose_valid: true,
            selfie_safe: true,
            failed_index: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VerificationResult {
    pub ok: bool,
    pub embedding: Option<Embedding>,
    pub estimated_age: Option<u8>,
    pub error: Option<String>,
    pub checks: VerificationChecks,
}

impl VerificationResult {
    fn ok(embedding: Embedding, estimated_age: Option<u8>) -> Self {
        Self {
            ok: true,
            embedding: Some(embedding),
            estimated_age,
            error: None,
            checks: VerificationChecks::default(),
        }
    }

    fn fail(error: impl Into<String>, checks: VerificationChecks) -> Self {
        Self {
            ok: false,
            embedding: None,
            estimated_age: None,
            error: Some(error.into()),
            checks,
        }
    }
}

pub fn verify_captured_photos(
    photos: &[CapturedPhotoAnalysis],
    challenge: &LivenessChallenge,
    thresholds: &VerificationThresholds,
) -> VerificationResult {
    if photos.len() != EXPECTED_PHOTO_COUNT {
        return VerificationResult::fail("se esperan 6 fotos", VerificationChecks::default());
    }

    let mut hashes = std::collections::HashSet::new();
    if !photos
        .iter()
        .all(|photo| hashes.insert(photo.hash.as_str()))
    {
        return VerificationResult::fail(
            "Se detectaron fotos duplicadas. Repite el proceso.",
            VerificationChecks {
                no_duplicates: false,
                ..VerificationChecks::default()
            },
        );
    }

    if let Some(index) = photos.iter().position(|photo| !photo.frame.face.detected) {
        return VerificationResult::fail(
            format!("No se detectó tu cara en la foto {}.", index + 1),
            VerificationChecks {
                all_have_face: false,
                failed_index: Some(index),
                ..VerificationChecks::default()
            },
        );
    }

    let embeddings = match collect_embeddings(photos) {
        Ok(embeddings) => embeddings,
        Err(index) => {
            return VerificationResult::fail(
                format!("No se pudo calcular el embedding de la foto {}.", index + 1),
                VerificationChecks {
                    all_have_face: false,
                    failed_index: Some(index),
                    ..VerificationChecks::default()
                },
            )
        }
    };

    let distances = match embeddings[1..]
        .iter()
        .map(|embedding| face_distance(&embeddings[0], embedding))
        .collect::<Result<Vec<_>, _>>()
    {
        Ok(distances) => distances,
        Err(_) => {
            return VerificationResult::fail(
                "Los embeddings no tienen dimensiones compatibles.",
                VerificationChecks {
                    same_person: false,
                    ..VerificationChecks::default()
                },
            )
        }
    };

    if !distances.iter().all(|d| *d < thresholds.same_person_max) {
        return VerificationResult::fail(
            "Las fotos no parecen ser de la misma persona.",
            VerificationChecks {
                same_person: false,
                ..VerificationChecks::default()
            },
        );
    }

    let min_dist = distances.iter().copied().fold(f32::INFINITY, f32::min);
    let max_dist = distances.iter().copied().fold(f32::NEG_INFINITY, f32::max);
    if max_dist - min_dist <= thresholds.poses_distinct_delta {
        return VerificationResult::fail(
            "Las fotos parecen demasiado similares. Asegúrate de seguir cada instrucción.",
            VerificationChecks {
                poses_distinct: false,
                ..VerificationChecks::default()
            },
        );
    }

    for (index, (photo, step)) in photos.iter().zip(challenge.steps.iter()).enumerate() {
        let pose = validate_pose(step, &photo.frame, challenge, thresholds);
        if !pose.valid {
            return VerificationResult::fail(
                format!(
                    "Foto {}: {} Repite la verificación.",
                    index + 1,
                    pose.message
                ),
                VerificationChecks {
                    pose_valid: false,
                    failed_index: Some(index),
                    ..VerificationChecks::default()
                },
            );
        }
    }

    let estimated_age = estimated_age(&photos[0].frame.face, &photos[1].frame.face);
    VerificationResult::ok(embeddings[0].clone(), estimated_age)
}

fn collect_embeddings(photos: &[CapturedPhotoAnalysis]) -> Result<Vec<Embedding>, usize> {
    photos
        .iter()
        .enumerate()
        .map(|(index, photo)| {
            photo
                .frame
                .face
                .embedding
                .clone()
                .filter(|embedding| !embedding.is_empty())
                .ok_or(index)
        })
        .collect()
}

fn estimated_age(a: &FaceAnalysis, b: &FaceAnalysis) -> Option<u8> {
    Some(((a.age? + b.age?) / 2.0).round().clamp(0.0, u8::MAX as f32) as u8)
}

pub fn face_distance(a: &[f32], b: &[f32]) -> Result<f32, FaceVerificationError> {
    if a.len() != b.len() || a.is_empty() {
        return Err(FaceVerificationError::InvalidEmbedding);
    }
    let sum = a
        .iter()
        .zip(b.iter())
        .map(|(a, b)| {
            let d = a - b;
            d * d
        })
        .sum::<f32>();
    Ok(sum.sqrt())
}