// Step 2 — the annotated facial-analysis hero (contours, width guides, landmark // dots, leader-line metric pills, score) plus the detail cards below. View-aware: // metrics the detected view can't measure are greyed; the nose overlay switches // to a silhouette on profiles. const fmtVal = (m) => { if (m.unit === "deg") return `${m.value}°`; if (m.unit === "mm") return `${m.value} mm`; return `${m.value}`; }; const NASAL_LABELS = { radixWidth: "Radix width", bridgeWidth: "Bridge width", alarWidth: "Alar width", nostrilSymmetry: "Nostril symmetry", columellaVisibility: "Columella visibility", tipDefinition: "Tip definition", tipBulbosity: "Tip bulbosity", nasalAxisDeviation: "Nasal axis deviation", alarGrooveVisibility: "Alar groove visibility", nasalBaseProportion: "Nasal base proportion", nasofrontalAngle: "Nasofrontal angle", nasolabialAngle: "Nasolabial angle", dorsalHumpSize: "Dorsal hump size", bridgeHeight: "Bridge height", tipProjection: "Tip projection", tipRotation: "Tip rotation", columellaLobule: "Columella-lobule", radixDepth: "Radix depth", nasalLength: "Nasal length", supratipBreak: "Supratip break", }; const VIEW_LABEL = { frontal: "Front-facing", three_quarter: "Three-quarter", profile: "Profile" }; const VIEW_NOTE = { frontal: "Front metrics are exact; side-profile depth metrics are estimates (≈).", three_quarter: "Partial view — front and side metrics shown; several are approximate.", profile: "Side-profile metrics are exact; front-only metrics need a front photo (N/A).", }; // ── MediaPipe contour loops (subset) and width guides ─────────────────────── const FACE_OVAL = [10,338,297,332,284,251,389,356,454,323,361,288,397,365,379,378,400,377,152,148,176,149,150,136,172,58,132,93,234,127,162,21,54,103,67,109,10]; const L_EYE = [33,7,163,144,145,153,154,155,133,173,157,158,159,160,161,246,33]; const R_EYE = [263,249,390,373,374,380,381,382,362,398,384,385,386,387,388,466,263]; const L_BROW = [70,63,105,66,107,55,65,52,53,46,70]; const R_BROW = [300,293,334,296,336,285,295,282,283,276,300]; const LIPS = [61,185,40,39,37,0,267,269,270,409,291,375,321,405,314,17,84,181,91,146,61]; const NOSE_BRIDGE = [168,6,197,195,5,4,1]; const CONTOURS = [FACE_OVAL, L_EYE, R_EYE, L_BROW, R_BROW, LIPS, NOSE_BRIDGE]; const WIDTH_GUIDES = [[468,473],[33,133],[263,362],[65,295],[49,279],[61,291],[116,345],[172,397],[176,400]]; const ANCHOR_DOTS = [10,9,168,1,2,152,234,454,116,345,172,397,49,279,33,133,263,362,468,473,65,295,61,291,176,400,164,0]; // Peripheral nose landmarks — angular-sorted into a clean closed outline (the // upper dorsal sides where the nose blends into the cheek are inherently soft, // so the curve is smoothed rather than implying a hard edge). const NOSE_OUTLINE_IDX = [168, 129, 49, 64, 98, 94, 327, 294, 279, 358]; // Profile silhouette midline (radix → dorsum → tip → columella → subnasale). const PROFILE_SILHOUETTE_IDX = [168, 6, 197, 195, 5, 4, 1, 19, 94, 2]; const NASAL_PTS_PROFILE = [168, 1, 2, 94]; // Catmull-Rom → cubic-Bézier smooth path through ordered points. function smoothPath(points, closed) { const n = points.length; if (n < 3) return points.map((p, i) => `${i ? "L" : "M"} ${p[0]} ${p[1]}`).join(" "); const at = (i) => points[closed ? (i + n) % n : Math.max(0, Math.min(n - 1, i))]; let d = `M ${points[0][0]} ${points[0][1]}`; const last = closed ? n : n - 1; for (let i = 0; i < last; i++) { const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2); const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2[0]} ${p2[1]}`; } return d + (closed ? " Z" : ""); } function noseOutlinePath(lm) { const pts = NOSE_OUTLINE_IDX.map((i) => lm[i]).filter(Boolean); if (pts.length < 3) return null; const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length; const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length; const ordered = pts.slice().sort((a, b) => Math.atan2(a[1] - cy, a[0] - cx) - Math.atan2(b[1] - cy, b[0] - cx)); return smoothPath(ordered, true); } function profileSilhouettePath(lm) { const pts = PROFILE_SILHOUETTE_IDX.map((i) => lm[i]).filter(Boolean); return smoothPath(pts, false); } // metric → anchor landmark + which gutter const PILLS = [ { key: "foreheadHeight", idx: 10, side: "right", name: "Forehead Height" }, { key: "browAlignment", idx: 295, side: "right", name: "Brow Alignment" }, { key: "interpupillaryDistance", idx: 473, side: "right", name: "Interpupillary" }, { key: "eyeWidthAvg", idx: 263, side: "right", name: "Eye Width" }, { key: "alarWidth", idx: 279, side: "right", name: "Nose Width" }, { key: "noseLength", idx: 1, side: "right", name: "Nose Length" }, { key: "philtrumLength", idx: 164, side: "right", name: "Philtrum" }, { key: "mouthWidth", idx: 291, side: "right", name: "Mouth Width" }, { key: "faceLength", idx: 234, side: "left", name: "Face Length" }, { key: "faceLengthOverWidth", idx: 127, side: "left", name: "Face L/W" }, { key: "cheekboneWidth", idx: 116, side: "left", name: "Cheekbone Width" }, { key: "jawWidth", idx: 172, side: "left", name: "Jaw Width" }, { key: "chinProjection", idx: 152, side: "left", name: "Chin Projection" }, { key: "leftRightDeviation", idx: 176, side: "left", name: "L/R Deviation" }, ]; function lookup(a, key) { return a.facial[key] || a.nasal.front[key] || a.nasal.side[key]; } // greedy de-overlap of pills within a gutter function placePills(pills, a, lm, w, h, gutter) { const PH = 34, GAP = 8, PW = 168; const byside = { left: [], right: [] }; for (const p of pills) { const m = lookup(a, p.key); if (!m || m.status === "na" || !lm[p.idx]) continue; byside[p.side].push({ ...p, m, ax: lm[p.idx][0], ay: lm[p.idx][1] }); } const out = []; for (const side of ["left", "right"]) { const items = byside[side].sort((u, v) => u.ay - v.ay); let prev = -Infinity; for (const it of items) { let y = Math.max(it.ay - PH / 2, prev + GAP); y = Math.min(y, h - PH); prev = y + PH; const x = side === "left" ? -gutter + 10 : w + gutter - PW - 10; out.push({ ...it, x, y, pw: PW, ph: PH }); } } return out; } const FaceOverlay = ({ session }) => { const [w, h] = session.image_size; const lm = session.landmarks_2d; const a = session.analysis; const isProfile = session.view && session.view.base === "profile"; const gutter = Math.max(210, w * 0.62); const vb = `${-gutter} 0 ${w + 2 * gutter} ${h}`; const pills = isProfile ? [] : placePills(PILLS, a, lm, w, h, gutter); const score = a.score; const pt = (i) => (lm[i] ? `${lm[i][0]},${lm[i][1]}` : ""); const padPct = (h / (w + 2 * gutter)) * 100; return (