import type { FaceLandmarksResponse } from '@/api/types' export type LandmarkVerdict = 'PASS' | 'REVIEW' | 'FAIL' export interface LandmarkScoreResult { score: number verdict: LandmarkVerdict reasons: string[] } function pts(arr?: number[][] | null): [number, number][] { return (arr || []).map((p) => [p[0], p[1]] as [number, number]) } function centroid(arr?: number[][] | null): [number, number] | null { const a = pts(arr) if (a.length === 0) return null const cx = a.reduce((s, p) => s + p[0], 0) / a.length const cy = a.reduce((s, p) => s + p[1], 0) / a.length return [cx, cy] } export function scoreFaceLandmarks(fa: FaceLandmarksResponse): LandmarkScoreResult { const lm = fa.landmarks || {} const lips = fa.lips || {} const reasons: string[] = [] // Dimension A: completeness (20) let a = 20 for (const k of ['left_eye', 'right_eye', 'nose'] as const) { const v = lm[k] || [] if (v.length === 0) { a -= 20 / 3 reasons.push(`missing ${k}`) } else if (![6, 8].includes(v.length)) { reasons.push(`${k} pts=${v.length}`) } } if (lips.outer_lips && ![0, 14].includes(lips.outer_lips.length)) { reasons.push(`outer_lips pts=${lips.outer_lips.length}`) } if (lips.inner_lips && ![0, 6].includes(lips.inner_lips.length)) { reasons.push(`inner_lips pts=${lips.inner_lips.length}`) } // Dimension B: geometry (50) let b = 50 const faceSize = Math.max(fa.width, fa.height, 1) const le = centroid(lm.left_eye) const re = centroid(lm.right_eye) const no = centroid(lm.nose) if (le && re && faceSize > 0) { const eyeDist = Math.hypot(le[0] - re[0], le[1] - re[1]) const eyeRatio = eyeDist / faceSize if (eyeRatio < 0.25) { const pen = Math.min(20, (0.25 - eyeRatio) * 200) b -= pen reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too small`) } else if (eyeRatio > 0.55) { const pen = Math.min(20, (eyeRatio - 0.55) * 100) b -= pen reasons.push(`eye_ratio=${eyeRatio.toFixed(2)} too large`) } const eyeDy = Math.abs(le[1] - re[1]) / faceSize if (eyeDy > 0.15) { const pen = Math.min(15, (eyeDy - 0.15) * 100) b -= pen reasons.push(`eye_dy=${eyeDy.toFixed(2)} asymmetric`) } if (no) { const noseBelow = (no[1] - Math.max(le[1], re[1])) / faceSize if (noseBelow < 0.03) { const pen = Math.min(15, (0.03 - noseBelow) * 50) b -= pen reasons.push(`nose_below=${noseBelow.toFixed(2)}`) } else if (noseBelow > 0.35) { const pen = Math.min(15, (noseBelow - 0.35) * 50) b -= pen reasons.push(`nose_below=${noseBelow.toFixed(2)} too low`) } const off = eyeDist > 0 ? (no[0] - (le[0] + re[0]) / 2) / eyeDist : 0 if (Math.abs(off) > 0.4) { const pen = Math.min(15, (Math.abs(off) - 0.4) * 20) b -= pen reasons.push(`nose_off_center=${off.toFixed(2)}`) } } } else { b -= 30 reasons.push('no eye centroids') } for (const k of ['left_eye', 'right_eye', 'nose'] as const) { for (const p of pts(lm[k])) { if (!(fa.x - 5 <= p[0] && p[0] <= fa.x + fa.width + 5 && fa.y - 5 <= p[1] && p[1] <= fa.y + fa.height + 5)) { b -= 10 reasons.push(`${k} outside bbox`) break } } } if (Math.min(fa.width, fa.height) < 64) { b -= 10 reasons.push('face < 64px') } // Dimension C: confidence & pose (30) let c = fa.confidence * 30 const pa = fa.pose_angle const maxPose = Math.max(Math.abs(pa?.yaw || 0), Math.abs(pa?.roll || 0), Math.abs(pa?.pitch || 0)) if (maxPose > 1.5) { reasons.push(`pose=${maxPose.toFixed(1)}`) } const score = Math.max(0, Math.min(100, a + b + c)) const verdict: LandmarkVerdict = score >= 70 ? 'PASS' : score >= 40 ? 'REVIEW' : 'FAIL' return { score: Math.round(score * 10) / 10, verdict, reasons } }