Spaces:
Running
Running
React face analysis app
Browse files- assets/index-B3zpsVgk.css +1 -0
- assets/index-Bv-INYt6.js +0 -0
- favicon.svg +1 -0
- icons.svg +24 -0
- index.html +13 -32
- js/mediapipe.js +0 -24
- js/scoring.js +0 -109
- js/ui.js +0 -139
- style.css +0 -7
assets/index-B3zpsVgk.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*{box-sizing:border-box;margin:0;padding:0}body{color:#e0e0e0;background:#0f0f13;min-height:100vh;font-family:system-ui,-apple-system,sans-serif}#root{max-width:900px;margin:0 auto;padding:24px}
|
assets/index-Bv-INYt6.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
favicon.svg
ADDED
|
|
icons.svg
ADDED
|
|
index.html
CHANGED
|
@@ -1,34 +1,15 @@
|
|
| 1 |
-
<!
|
| 2 |
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
</
|
| 11 |
-
<
|
| 12 |
-
<
|
| 13 |
-
<
|
| 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>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Face Analysis</title>
|
| 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" crossorigin src="/assets/index-Bv-INYt6.js"></script>
|
| 10 |
+
<link rel="stylesheet" crossorigin href="/assets/index-B3zpsVgk.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
</body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</html>
|
js/mediapipe.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,139 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 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; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|