use crate::common::{Point, Rect};
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LandmarkSet {
FivePoint,
Extended,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LandmarkRole {
Jaw,
LeftEyebrow,
RightEyebrow,
NoseBridge,
NoseBase,
LeftEye,
RightEye,
OuterLip,
InnerLip,
EyeCenter,
NoseTip,
MouthCorner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Landmark {
pub position: Point,
pub role: LandmarkRole,
pub index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaceLandmarks {
pub points: Vec<Landmark>,
pub source_bbox: Rect,
pub landmark_set: LandmarkSet,
pub metrics: FaceGeometryMetrics,
}
impl FaceLandmarks {
#[must_use]
pub fn by_role(&self, role: LandmarkRole) -> Vec<&Landmark> {
self.points.iter().filter(|l| l.role == role).collect()
}
#[must_use]
pub fn centroid_of_role(&self, role: LandmarkRole) -> Option<Point> {
let pts: Vec<&Landmark> = self.by_role(role);
if pts.is_empty() {
return None;
}
let sum_x: f32 = pts.iter().map(|l| l.position.x).sum();
let sum_y: f32 = pts.iter().map(|l| l.position.y).sum();
let n = pts.len() as f32;
Some(Point::new(sum_x / n, sum_y / n))
}
#[must_use]
pub fn inter_pupillary_distance(&self) -> Option<f32> {
let left = self
.centroid_of_role(LandmarkRole::LeftEye)
.or_else(|| self.centroid_of_role(LandmarkRole::EyeCenter));
let right = self.centroid_of_role(LandmarkRole::RightEye).or_else(|| {
let pts = self.by_role(LandmarkRole::EyeCenter);
pts.get(1).map(|l| l.position)
});
match (left, right) {
(Some(l), Some(r)) => Some(l.distance(&r)),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FaceGeometryMetrics {
pub face_width: f32,
pub face_height: f32,
pub aspect_ratio: f32,
pub symmetry_score: f32,
pub estimated_yaw_deg: f32,
pub coverage: f32,
}
#[derive(Debug, Clone)]
pub struct FaceLandmarkDetector {
pub landmark_set: LandmarkSet,
}
impl FaceLandmarkDetector {
#[must_use]
pub fn new(landmark_set: LandmarkSet) -> Self {
Self { landmark_set }
}
#[must_use]
pub fn detect_from_bbox(&self, bbox: &Rect) -> FaceLandmarks {
let points = match self.landmark_set {
LandmarkSet::FivePoint => five_point_landmarks(bbox),
LandmarkSet::Extended => extended_landmarks(bbox),
};
let metrics = compute_metrics(&points, bbox, self.landmark_set);
FaceLandmarks {
points,
source_bbox: *bbox,
landmark_set: self.landmark_set,
metrics,
}
}
#[must_use]
pub fn detect_from_coords(&self, x: f32, y: f32, w: f32, h: f32) -> FaceLandmarks {
self.detect_from_bbox(&Rect::new(x, y, w, h))
}
}
const FIVE_POINT: [(f32, f32, LandmarkRole); 5] = [
(0.36, 0.38, LandmarkRole::EyeCenter), (0.64, 0.38, LandmarkRole::EyeCenter), (0.50, 0.62, LandmarkRole::NoseTip), (0.33, 0.75, LandmarkRole::MouthCorner), (0.67, 0.75, LandmarkRole::MouthCorner), ];
fn five_point_landmarks(bbox: &Rect) -> Vec<Landmark> {
FIVE_POINT
.iter()
.enumerate()
.map(|(i, &(fx, fy, role))| Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role,
index: i,
})
.collect()
}
fn extended_landmarks(bbox: &Rect) -> Vec<Landmark> {
let mut pts = Vec::with_capacity(68);
let mut idx = 0usize;
for i in 0..17usize {
let t = i as f32 / 16.0; let angle = PI + t * PI; let fx = 0.5 + 0.46 * angle.cos();
let fy = 0.5 + 0.55 * angle.sin().abs();
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::Jaw,
index: idx,
});
idx += 1;
}
let left_brow: [(f32, f32); 5] = [
(0.19, 0.30),
(0.26, 0.25),
(0.34, 0.23),
(0.40, 0.25),
(0.45, 0.28),
];
for &(fx, fy) in &left_brow {
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::LeftEyebrow,
index: idx,
});
idx += 1;
}
let right_brow: [(f32, f32); 5] = [
(0.55, 0.28),
(0.60, 0.25),
(0.66, 0.23),
(0.74, 0.25),
(0.81, 0.30),
];
for &(fx, fy) in &right_brow {
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::RightEyebrow,
index: idx,
});
idx += 1;
}
let nose_bridge: [(f32, f32); 4] = [(0.50, 0.35), (0.50, 0.43), (0.50, 0.51), (0.50, 0.58)];
for &(fx, fy) in &nose_bridge {
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::NoseBridge,
index: idx,
});
idx += 1;
}
let nose_base: [(f32, f32); 5] = [
(0.37, 0.63),
(0.43, 0.66),
(0.50, 0.67),
(0.57, 0.66),
(0.63, 0.63),
];
for &(fx, fy) in &nose_base {
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::NoseBase,
index: idx,
});
idx += 1;
}
let left_eye_cx = 0.36_f32;
let left_eye_cy = 0.38_f32;
for i in 0..6usize {
let angle = 2.0 * PI * i as f32 / 6.0;
let fx = left_eye_cx + 0.075 * angle.cos();
let fy = left_eye_cy + 0.038 * angle.sin();
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::LeftEye,
index: idx,
});
idx += 1;
}
let right_eye_cx = 0.64_f32;
let right_eye_cy = 0.38_f32;
for i in 0..6usize {
let angle = 2.0 * PI * i as f32 / 6.0;
let fx = right_eye_cx + 0.075 * angle.cos();
let fy = right_eye_cy + 0.038 * angle.sin();
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::RightEye,
index: idx,
});
idx += 1;
}
for i in 0..12usize {
let angle = 2.0 * PI * i as f32 / 12.0;
let fx = 0.50 + 0.13 * angle.cos();
let fy = 0.76 + 0.055 * angle.sin();
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::OuterLip,
index: idx,
});
idx += 1;
}
for i in 0..8usize {
let angle = 2.0 * PI * i as f32 / 8.0;
let fx = 0.50 + 0.09 * angle.cos();
let fy = 0.76 + 0.038 * angle.sin();
pts.push(Landmark {
position: Point::new(bbox.x + fx * bbox.width, bbox.y + fy * bbox.height),
role: LandmarkRole::InnerLip,
index: idx,
});
idx += 1;
}
debug_assert_eq!(
pts.len(),
68,
"Extended landmark set must have exactly 68 points"
);
pts
}
fn compute_metrics(points: &[Landmark], bbox: &Rect, set: LandmarkSet) -> FaceGeometryMetrics {
let xs: Vec<f32> = points.iter().map(|l| l.position.x).collect();
let ys: Vec<f32> = points.iter().map(|l| l.position.y).collect();
let min_x = xs.iter().cloned().fold(f32::INFINITY, f32::min);
let max_x = xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let min_y = ys.iter().cloned().fold(f32::INFINITY, f32::min);
let max_y = ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let face_width = (max_x - min_x).max(0.0);
let face_height = (max_y - min_y).max(0.0);
let aspect_ratio = if face_height > 0.0 {
face_width / face_height
} else {
1.0
};
let mid_x = (min_x + max_x) / 2.0;
let symmetry_score = compute_symmetry(points, mid_x, set);
let estimated_yaw_deg = estimate_yaw(points, mid_x);
let bbox_area = bbox.width * bbox.height;
let coverage = if bbox_area > 0.0 {
(face_width * face_height / bbox_area).min(1.0)
} else {
0.0
};
FaceGeometryMetrics {
face_width,
face_height,
aspect_ratio,
symmetry_score,
estimated_yaw_deg,
coverage,
}
}
fn compute_symmetry(points: &[Landmark], mid_x: f32, set: LandmarkSet) -> f32 {
let pairs: &[(LandmarkRole, LandmarkRole)] = match set {
LandmarkSet::FivePoint => &[],
LandmarkSet::Extended => &[
(LandmarkRole::LeftEye, LandmarkRole::RightEye),
(LandmarkRole::LeftEyebrow, LandmarkRole::RightEyebrow),
],
};
if pairs.is_empty() {
let eyes: Vec<&Landmark> = points
.iter()
.filter(|l| l.role == LandmarkRole::EyeCenter)
.collect();
if eyes.len() >= 2 {
let d_left = (eyes[0].position.x - mid_x).abs();
let d_right = (eyes[1].position.x - mid_x).abs();
let max_d = d_left.max(d_right).max(1e-6);
let asym = (d_left - d_right).abs() / max_d;
return (1.0 - asym).max(0.0);
}
return 1.0;
}
let mut total_asym = 0.0_f32;
let mut count = 0usize;
for &(left_role, right_role) in pairs {
let left_pts: Vec<&Landmark> = points.iter().filter(|l| l.role == left_role).collect();
let right_pts: Vec<&Landmark> = points.iter().filter(|l| l.role == right_role).collect();
if left_pts.is_empty() || right_pts.is_empty() {
continue;
}
let left_cx: f32 =
left_pts.iter().map(|l| l.position.x).sum::<f32>() / left_pts.len() as f32;
let right_cx: f32 =
right_pts.iter().map(|l| l.position.x).sum::<f32>() / right_pts.len() as f32;
let d_left = (left_cx - mid_x).abs();
let d_right = (right_cx - mid_x).abs();
let max_d = d_left.max(d_right).max(1e-6);
total_asym += (d_left - d_right).abs() / max_d;
count += 1;
}
if count == 0 {
return 1.0;
}
(1.0 - total_asym / count as f32).clamp(0.0, 1.0)
}
fn estimate_yaw(points: &[Landmark], mid_x: f32) -> f32 {
let nose: Vec<&Landmark> = points
.iter()
.filter(|l| matches!(l.role, LandmarkRole::NoseTip | LandmarkRole::NoseBridge))
.collect();
if nose.is_empty() {
return 0.0;
}
let nose_x: f32 = nose.iter().map(|l| l.position.x).sum::<f32>() / nose.len() as f32;
let all_x: Vec<f32> = points.iter().map(|l| l.position.x).collect();
let min_x = all_x.iter().cloned().fold(f32::INFINITY, f32::min);
let max_x = all_x.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let half_width = ((max_x - min_x) / 2.0).max(1.0);
let offset = (nose_x - mid_x) / half_width; offset * 45.0 }
#[cfg(test)]
mod tests {
use super::*;
fn test_bbox() -> Rect {
Rect::new(50.0, 40.0, 100.0, 120.0)
}
#[test]
fn test_five_point_count() {
let det = FaceLandmarkDetector::new(LandmarkSet::FivePoint);
let lms = det.detect_from_bbox(&test_bbox());
assert_eq!(lms.points.len(), 5);
}
#[test]
fn test_extended_count() {
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let lms = det.detect_from_bbox(&test_bbox());
assert_eq!(lms.points.len(), 68);
}
#[test]
fn test_five_point_roles() {
let det = FaceLandmarkDetector::new(LandmarkSet::FivePoint);
let lms = det.detect_from_bbox(&test_bbox());
let roles: Vec<LandmarkRole> = lms.points.iter().map(|l| l.role).collect();
assert_eq!(roles[0], LandmarkRole::EyeCenter);
assert_eq!(roles[1], LandmarkRole::EyeCenter);
assert_eq!(roles[2], LandmarkRole::NoseTip);
assert_eq!(roles[3], LandmarkRole::MouthCorner);
assert_eq!(roles[4], LandmarkRole::MouthCorner);
}
#[test]
fn test_landmarks_inside_bbox() {
let bbox = test_bbox();
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let lms = det.detect_from_bbox(&bbox);
let margin = 10.0_f32;
for l in &lms.points {
assert!(
l.position.x >= bbox.x - margin && l.position.x <= bbox.x + bbox.width + margin,
"x={} out of range",
l.position.x
);
assert!(
l.position.y >= bbox.y - margin && l.position.y <= bbox.y + bbox.height + margin,
"y={} out of range",
l.position.y
);
}
}
#[test]
fn test_inter_pupillary_distance_five_point() {
let det = FaceLandmarkDetector::new(LandmarkSet::FivePoint);
let bbox = Rect::new(0.0, 0.0, 100.0, 120.0);
let lms = det.detect_from_bbox(&bbox);
let eye_pts: Vec<&Landmark> = lms.by_role(LandmarkRole::EyeCenter);
assert_eq!(eye_pts.len(), 2, "should have 2 eye-centre landmarks");
let ipd = eye_pts[0].position.distance(&eye_pts[1].position);
assert!((ipd - 28.0).abs() < 2.0, "IPD={}", ipd);
}
#[test]
fn test_by_role_extended() {
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let lms = det.detect_from_bbox(&test_bbox());
let jaw = lms.by_role(LandmarkRole::Jaw);
assert_eq!(jaw.len(), 17);
let outer_lip = lms.by_role(LandmarkRole::OuterLip);
assert_eq!(outer_lip.len(), 12);
let inner_lip = lms.by_role(LandmarkRole::InnerLip);
assert_eq!(inner_lip.len(), 8);
}
#[test]
fn test_centroid_of_role() {
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let bbox = Rect::new(0.0, 0.0, 100.0, 100.0);
let lms = det.detect_from_bbox(&bbox);
let centroid = lms.centroid_of_role(LandmarkRole::LeftEye);
assert!(centroid.is_some());
let c = centroid.unwrap();
assert!(
c.x < 55.0,
"Left eye centroid x={} should be in left half",
c.x
);
}
#[test]
fn test_geometry_metrics_aspect_ratio() {
let det = FaceLandmarkDetector::new(LandmarkSet::FivePoint);
let bbox = Rect::new(0.0, 0.0, 100.0, 200.0);
let lms = det.detect_from_bbox(&bbox);
let ar = lms.metrics.aspect_ratio;
assert!(ar < 1.2, "aspect_ratio={} expected portrait-ish", ar);
}
#[test]
fn test_symmetry_score_range() {
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let lms = det.detect_from_bbox(&test_bbox());
let s = lms.metrics.symmetry_score;
assert!(
(0.0..=1.0).contains(&s),
"symmetry_score={} out of [0,1]",
s
);
}
#[test]
fn test_detect_from_coords() {
let det = FaceLandmarkDetector::new(LandmarkSet::FivePoint);
let lms = det.detect_from_coords(10.0, 20.0, 80.0, 100.0);
assert_eq!(lms.points.len(), 5);
assert_eq!(lms.source_bbox.x, 10.0);
}
#[test]
fn test_yaw_approximately_zero_for_frontal() {
let det = FaceLandmarkDetector::new(LandmarkSet::Extended);
let bbox = Rect::new(0.0, 0.0, 100.0, 100.0);
let lms = det.detect_from_bbox(&bbox);
assert!(
lms.metrics.estimated_yaw_deg.abs() < 10.0,
"yaw={} expected near 0 for frontal model",
lms.metrics.estimated_yaw_deg
);
}
}