| window.WhistleCoach = window.WhistleCoach || {}; | |
| window.WhistleCoach.FaceFeatures = (() => { | |
| const clamp = (value, minimum = 0, maximum = 1) => | |
| Math.min(maximum, Math.max(minimum, value)); | |
| function toFeaturePayload(face) { | |
| return { | |
| face_visible: Boolean(face?.visible), | |
| face_centered: Boolean(face?.centered), | |
| mouth_opening_ratio: Number(face?.openingRatio || 0), | |
| lip_roundness_score: Number(face?.puckerScore || 0), | |
| upper_lip_lift_score: Number(face?.upperLipLiftScore || 0), | |
| jaw_stability_score: Number(face?.jawScore || 0), | |
| mouth_symmetry_score: Number(face?.symmetry || 0), | |
| mouth_shape_score: Number(face?.score || 0), | |
| }; | |
| } | |
| function classifyMouthShape(features) { | |
| if (!features.face_visible || !features.face_centered) return "no_face"; | |
| if (features.mouth_opening_ratio > 0.34 || features.jaw_stability_score < 0.45) { | |
| return "mouth_too_open"; | |
| } | |
| if (features.lip_roundness_score < 0.52) return "not_rounded"; | |
| if (features.mouth_symmetry_score < 0.58) return "asymmetric_mouth"; | |
| if (features.mouth_shape_score >= 0.62) return "mouth_ready"; | |
| return "adjust_mouth"; | |
| } | |
| function smoothFeature(previous, next, alpha = 0.28) { | |
| if (!previous) return next; | |
| const output = { ...next }; | |
| for (const [key, value] of Object.entries(next)) { | |
| if (typeof value === "number") { | |
| output[key] = clamp(previous[key] * (1 - alpha) + value * alpha); | |
| } | |
| } | |
| return output; | |
| } | |
| return { | |
| toFeaturePayload, | |
| classifyMouthShape, | |
| smoothFeature, | |
| }; | |
| })(); | |