asterioskryos commited on
Commit
a258da9
·
verified ·
1 Parent(s): 53cc695

Initial face analysis with MediaPipe

Browse files
Files changed (5) hide show
  1. index.html +33 -18
  2. js/mediapipe.js +24 -0
  3. js/scoring.js +109 -0
  4. js/ui.js +139 -0
  5. style.css +7 -28
index.html CHANGED
@@ -1,19 +1,34 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Face Analysis</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/wasm/vision_bundle.js" crossorigin="anonymous"></script>
9
+ <script type="module" src="js/ui.js"></script>
10
+ </head>
11
+ <body>
12
+ <div id="app">
13
+ <h1>Face Analysis</h1>
14
+
15
+ <div id="uploadArea">
16
+ <div id="uploadPrompt">
17
+ <p>Drop photo here or click to upload</p>
18
+ </div>
19
+ <img id="preview" style="display:none">
20
+ <input type="file" id="fileInput" accept="image/*">
21
+ </div>
22
+
23
+ <button id="analyzeBtn" disabled>Analyze</button>
24
+ <div id="status">Loading...</div>
25
+
26
+ <canvas id="resultCanvas" style="display:none"></canvas>
27
+
28
+ <div id="results" style="display:none">
29
+ <h2>Overall: <span id="overallScore"></span></h2>
30
+ <div id="scoreGrid"></div>
31
+ </div>
32
+ </div>
33
+ </body>
34
  </html>
js/mediapipe.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let faceLandmarker = null;
2
+
3
+ export async function initModel() {
4
+ const { FilesetResolver, FaceLandmarker } = window.vision;
5
+ const resolver = await FilesetResolver.forVisionTasks(
6
+ 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/wasm/'
7
+ );
8
+ faceLandmarker = await FaceLandmarker.createFromOptions(resolver, {
9
+ baseOptions: {
10
+ modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
11
+ delegate: 'GPU',
12
+ },
13
+ runningMode: 'IMAGE',
14
+ numFaces: 1,
15
+ });
16
+ return true;
17
+ }
18
+
19
+ export function detectLandmarks(image) {
20
+ if (!faceLandmarker) return null;
21
+ const result = faceLandmarker.detect(image);
22
+ if (!result.faceLandmarks || result.faceLandmarks.length === 0) return null;
23
+ return result.faceLandmarks[0];
24
+ }
js/scoring.js ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const PAIRS = [
2
+ [33,263],[133,362],[157,387],[158,386],[159,385],[160,384],[161,398],[173,466],[246,380],
3
+ [46,276],[53,283],[52,282],[65,295],[66,296],[70,300],[105,334],[107,336],
4
+ [234,454],[93,351],[132,361],[58,288],[172,397],[136,377],[150,379],[149,378],[176,401],[148,384],
5
+ [61,291],[39,269],[37,267],[84,314],[181,321],[91,308],[146,375],[174,399],[89,359],[169,420],
6
+ ];
7
+ const LEFT_EYE=[33,133,157,158,159,160,161,173,246];
8
+ const RIGHT_EYE=[362,263,387,386,385,384,398,466,380];
9
+ const LEFT_BROW=[46,53,52,65,66,70,105,107];
10
+ const RIGHT_BROW=[276,283,282,295,296,300,334,336];
11
+ const LIPS=[0,37,39,40,61,84,91,146,178,181,185,267,269,270,291,308,314,317,321,324,325,375,402,405];
12
+ const FOREHEAD=10, CHIN=152, L_CHEEK=234, R_CHEEK=454;
13
+ const NOSE_TIP=1, NOSE_BRIDGE=6, UPPER_LIP=13, LOWER_LIP=14;
14
+ const L_EYE=159, R_EYE=386;
15
+
16
+ function d(a,b){return Math.hypot(a.x-b.x,a.y-b.y)}
17
+
18
+ export function computeAll(pts){
19
+ if(!pts||pts.length<468)return null;
20
+ const sym=computeSymmetry(pts);
21
+ const props=computeProps(pts);
22
+ const eyeS=featureSym(pts,LEFT_EYE,RIGHT_EYE);
23
+ const browS=featureSym(pts,LEFT_BROW,RIGHT_BROW);
24
+ const lipS=lipScore(pts);
25
+ const[chinShape,chinS]=chinScore(pts);
26
+ const overall=sym*0.35+props*0.35+eyeS*0.1+browS*0.05+lipS*0.1+chinS*0.05;
27
+ return{
28
+ overall:Math.round(overall*10)/10,
29
+ symmetry:Math.round(sym*10)/10,
30
+ proportions:Math.round(props*10)/10,
31
+ eyeSymmetry:Math.round(eyeS*10)/10,
32
+ browSymmetry:Math.round(browS*10)/10,
33
+ lipFullness:Math.round(lipS*10)/10,
34
+ chinShape,chinScore:Math.round(chinS*10)/10,
35
+ };
36
+ }
37
+
38
+ function computeSymmetry(pts){
39
+ let errs=[];
40
+ for(const[l,r]of PAIRS){
41
+ if(l<pts.length&&r<pts.length){
42
+ const mid=(pts[l].x+pts[r].x)/2;
43
+ errs.push(Math.abs(mid-0.5));
44
+ }
45
+ }
46
+ if(!errs.length)return 50;
47
+ const avg=errs.reduce((a,b)=>a+b,0)/errs.length;
48
+ return Math.max(0,Math.min(100,100-avg*500));
49
+ }
50
+
51
+ function featureSym(pts,left,right){
52
+ let ld=left.map(i=>pts[i]).filter(Boolean);
53
+ let rd=right.map(i=>pts[i]).filter(Boolean);
54
+ const len=Math.min(ld.length,rd.length);
55
+ if(!len)return 100;
56
+ let tot=0;
57
+ for(let i=0;i<len;i++)tot+=d(ld[i],rd[i]);
58
+ return Math.max(0,Math.min(100,100-(tot/len)*1000));
59
+ }
60
+
61
+ function lipScore(pts){
62
+ const lp=LIPS.map(i=>pts[i]).filter(Boolean);
63
+ if(lp.length<4)return 50;
64
+ const xs=lp.map(p=>p.x),ys=lp.map(p=>p.y);
65
+ const w=Math.max(...xs)-Math.min(...xs);
66
+ const h=Math.max(...ys)-Math.min(...ys);
67
+ if(!w)return 50;
68
+ const ratio=h/w,ideal=0.35;
69
+ return Math.max(0,Math.min(100,100-Math.abs(ratio-ideal)/ideal*150));
70
+ }
71
+
72
+ function chinScore(pts){
73
+ if(pts.length<=CHIN)return['Unknown',50];
74
+ const chin=pts[CHIN],jl=pts[136],jr=pts[377];
75
+ const jw=d(jl,jr);
76
+ const cj=Math.abs(chin.y-(jl.y+jr.y)/2);
77
+ const ratio=jw?cj/jw:0;
78
+ let shape=ratio<0.25?'Round':ratio<0.35?'Square':'Pointed';
79
+ return[shape,Math.max(0,Math.min(100,100-Math.abs(ratio-0.3)*300))];
80
+ }
81
+
82
+ function computeProps(pts){
83
+ const r={};
84
+ if(pts.length>Math.max(FOREHEAD,CHIN,L_CHEEK,R_CHEEK)){
85
+ const fw=pts[R_CHEEK].x-pts[L_CHEEK].x;
86
+ const fh=pts[CHIN].y-pts[FOREHEAD].y;
87
+ r.faceRatio=fh?fw/fh:0;
88
+ }
89
+ if(pts.length>Math.max(NOSE_BRIDGE,NOSE_TIP,L_EYE,R_EYE,UPPER_LIP,LOWER_LIP)){
90
+ const ed=d(pts[L_EYE],pts[R_EYE]);
91
+ const nl=d(pts[NOSE_BRIDGE],pts[NOSE_TIP]);
92
+ const mh=d(pts[UPPER_LIP],pts[LOWER_LIP]);
93
+ r.eyeToNose=nl?ed/nl:0;
94
+ r.noseToMouth=mh?nl/mh:0;
95
+ const fw=d(pts[L_CHEEK],pts[R_CHEEK]);
96
+ r.eyeToFace=fw?ed/fw:0;
97
+ }
98
+ return ratioScore(r);
99
+ }
100
+
101
+ function ratioScore(r){
102
+ const vals=[
103
+ r.faceRatio?Math.max(0,100-Math.abs(r.faceRatio-0.72)/0.72*150):null,
104
+ r.eyeToNose?Math.max(0,100-Math.abs(r.eyeToNose-1.6)/1.6*100):null,
105
+ r.noseToMouth?Math.max(0,100-Math.abs(r.noseToMouth-2)/2*100):null,
106
+ r.eyeToFace?Math.max(0,100-Math.abs(r.eyeToFace-0.46)/0.46*150):null,
107
+ ].filter(v=>v!==null);
108
+ return vals.length?vals.reduce((a,b)=>a+b,0)/vals.length:50;
109
+ }
js/ui.js ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { initModel, detectLandmarks } from './mediapipe.js';
2
+ import { computeAll } from './scoring.js';
3
+
4
+ let currentImage = null;
5
+
6
+ export function initUI() {
7
+ const upload = document.getElementById('uploadArea');
8
+ const input = document.getElementById('fileInput');
9
+ const btn = document.getElementById('analyzeBtn');
10
+ const status = document.getElementById('status');
11
+
12
+ status.textContent = 'Loading AI model...';
13
+
14
+ upload.onclick = () => input.click();
15
+ upload.ondragover = e => { e.preventDefault(); upload.style.borderColor = '#7c5cfc'; };
16
+ upload.ondragleave = () => { upload.style.borderColor = '#444'; };
17
+ upload.ondrop = e => {
18
+ e.preventDefault();
19
+ if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
20
+ };
21
+ input.onchange = () => { if (input.files[0]) handleFile(input.files[0]); };
22
+ btn.onclick = analyze;
23
+
24
+ initModel().then(() => {
25
+ status.textContent = 'Ready. Upload a photo.';
26
+ btn.disabled = false;
27
+ }).catch(e => {
28
+ status.textContent = 'Failed to load model: ' + e.message;
29
+ });
30
+ }
31
+
32
+ function handleFile(file) {
33
+ if (!file || !file.type.startsWith('image/')) return;
34
+ const reader = new FileReader();
35
+ reader.onload = e => {
36
+ const img = new Image();
37
+ img.onload = () => {
38
+ currentImage = img;
39
+ document.getElementById('preview').src = e.target.result;
40
+ document.getElementById('preview').style.display = 'block';
41
+ document.getElementById('uploadPrompt').style.display = 'none';
42
+ document.getElementById('analyzeBtn').disabled = false;
43
+ document.getElementById('results').style.display = 'none';
44
+ };
45
+ img.src = e.target.result;
46
+ };
47
+ reader.readAsDataURL(file);
48
+ }
49
+
50
+ async function analyze() {
51
+ if (!currentImage) return;
52
+ const status = document.getElementById('status');
53
+ const btn = document.getElementById('analyzeBtn');
54
+ status.textContent = 'Analyzing...';
55
+ btn.disabled = true;
56
+
57
+ try {
58
+ const landmarks = detectLandmarks(currentImage);
59
+ if (!landmarks) {
60
+ status.textContent = 'No face detected. Try a front-facing photo.';
61
+ btn.disabled = false;
62
+ return;
63
+ }
64
+
65
+ const scores = computeAll(landmarks);
66
+ if (!scores) {
67
+ status.textContent = 'Could not compute scores.';
68
+ btn.disabled = false;
69
+ return;
70
+ }
71
+
72
+ drawLandmarks(currentImage, landmarks);
73
+ displayScores(scores);
74
+ status.textContent = `Done — ${landmarks.length} landmarks detected.`;
75
+ } catch (e) {
76
+ status.textContent = 'Error: ' + e.message;
77
+ }
78
+ btn.disabled = false;
79
+ }
80
+
81
+ function drawLandmarks(img, landmarks) {
82
+ const canvas = document.getElementById('resultCanvas');
83
+ canvas.width = img.width;
84
+ canvas.height = img.height;
85
+ canvas.style.display = 'block';
86
+ const ctx = canvas.getContext('2d');
87
+ ctx.drawImage(img, 0, 0);
88
+
89
+ const conns = [
90
+ [33,133],[133,157],[157,158],[158,159],[159,160],[160,161],[161,173],[173,246],[246,33],
91
+ [362,263],[263,387],[387,386],[386,385],[385,384],[384,398],[398,466],[466,380],[380,362],
92
+ [46,53],[53,52],[52,65],[65,66],[66,70],[70,105],[105,107],[107,46],
93
+ [276,283],[283,282],[282,295],[295,296],[296,300],[300,334],[334,336],[336,276],
94
+ [61,146],[146,91],[91,181],[181,84],[84,17],[17,314],[314,405],[405,321],[321,375],[375,291],[291,409],[409,270],[270,269],[269,0],
95
+ [10,338],[338,297],[297,332],[332,284],[284,251],[251,389],[389,356],[356,454],[454,323],[323,361],[361,288],[288,397],[397,365],[365,379],[379,378],[378,400],[400,377],[377,152],[152,148],[148,176],[176,149],[149,150],[150,136],[136,172],[172,58],[58,132],[132,93],[93,234],[234,127],[127,162],[162,21],[21,54],[54,103],[103,67],[67,109],[109,10],
96
+ ];
97
+
98
+ ctx.strokeStyle = '#00ff88';
99
+ ctx.lineWidth = 2;
100
+ for (const [i, j] of conns) {
101
+ if (i < landmarks.length && j < landmarks.length) {
102
+ ctx.beginPath();
103
+ ctx.moveTo(landmarks[i].x * img.width, landmarks[i].y * img.height);
104
+ ctx.lineTo(landmarks[j].x * img.width, landmarks[j].y * img.height);
105
+ ctx.stroke();
106
+ }
107
+ }
108
+
109
+ ctx.fillStyle = '#ff4444';
110
+ for (const lm of landmarks) {
111
+ ctx.beginPath();
112
+ ctx.arc(lm.x * img.width, lm.y * img.height, 2, 0, 2 * Math.PI);
113
+ ctx.fill();
114
+ }
115
+ }
116
+
117
+ function displayScores(s) {
118
+ document.getElementById('results').style.display = 'block';
119
+ document.getElementById('overallScore').textContent = s.overall;
120
+
121
+ const items = [
122
+ ['Facial Symmetry', s.symmetry],
123
+ ['Facial Proportions', s.proportions],
124
+ ['Eye Symmetry', s.eyeSymmetry],
125
+ ['Eyebrow Symmetry', s.browSymmetry],
126
+ ['Lip Fullness', s.lipFullness],
127
+ [`Chin (${s.chinShape})`, s.chinScore],
128
+ ];
129
+
130
+ const grid = document.getElementById('scoreGrid');
131
+ grid.innerHTML = '';
132
+ for (const [label, val] of items) {
133
+ const div = document.createElement('div');
134
+ div.className = 'score';
135
+ const color = val >= 70 ? '#4ade80' : val >= 50 ? '#facc15' : '#f87171';
136
+ div.innerHTML = `<span>${label}</span><span style="color:${color};font-weight:700;font-size:1.2em">${val}</span>`;
137
+ grid.appendChild(div);
138
+ }
139
+ }
style.css CHANGED
@@ -1,28 +1,7 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
1
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #111; color: #eee; }
2
+ #uploadArea { border: 2px dashed #444; padding: 30px; text-align: center; cursor: pointer; margin: 16px 0; }
3
+ #uploadArea:hover { border-color: #7c5cfc; }
4
+ img, canvas { max-width: 100%; }
5
+ button { padding: 10px 24px; font-size: 16px; cursor: pointer; }
6
+ .score { display: flex; justify-content: space-between; padding: 8px 12px; background: #222; margin: 4px 0; border-radius: 6px; }
7
+ #status { margin-top: 8px; color: #888; }