Most "rate my face" tools do one of two sketchy things: they upload your selfie to a server, or they bill an AI vision API on every single scan. I wanted neither. So when I built ships a FaceLandmarker that runs on WebGL and returns 478 3D landmarks per face — entirely client-side. I self-host the model + wasm so there's no third-party CDN call (and it loads fine from regions where Google's CDN is flaky):
import { FaceLandmarker, FilesetResolver } from "@mediapipe/tasks-vision";
const vision = await FilesetResolver.forVisionTasks("/mediapipe/wasm");
const landmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: { modelAssetPath: "/mediapipe/face_landmarker.task" },
runningMode: "IMAGE",
numFaces: 1,
});
const result = landmarker.detect(imageElement);
const pts = result.faceLandmarks[0]; // 478 points, each {x, y, z} normalized 0..1
That pts array is everything I need. The image element is read straight from an <input type="file"> and a canvas — it never gets POSTed anywhere.
Step 2: turn coordinates into metrics
Every metric is "how close is this to an ideal, within a tolerance." So the core helper is just a clamped closeness function:
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
// 1.0 = exactly ideal, 0.0 = a full tolerance away or worse
const closeness = (deviation: number, tolerance: number) =>
clamp01(1 - Math.abs(deviation) / tolerance);
Symmetry — reflect the left-side landmarks across the face's vertical midline and measure how far they miss their right-side partners:
function symmetry(pts) {
const midX = (pts[33].x + pts[263].x) / 2; // midpoint between outer eye corners
let totalDev = 0;
for (const [l, r] of MIRROR_PAIRS) {
const reflectedLeftX = 2 * midX - pts[l].x;
totalDev += Math.abs(reflectedLeftX - pts[r].x);
}
return closeness(totalDev / MIRROR_PAIRS.length, SYMMETRY_TOL);
}
Facial thirds — forehead-to-brow, brow-to-nose-base, nose-base-to-chin should be roughly equal. Score the deviation from a perfect 1:1:1.
Facial fifths — the face should be about five eye-widths wide. Score the variance across those five segments.
Canthal tilt — the angle from inner to outer eye corner (a positive/upward tilt reads as "more attractive" in most studies). Computed per eye, then I penalize asymmetry between the two.
FWHR — facial width-to-height ratio, bizygomatic width over upper-face height.
Step 3: weight them into one score
Different metrics matter differently, so it's a weighted blend, then scaled to 0–10:
const dims = [
{ key: "symmetry", weight: 0.30 },
{ key: "thirds", weight: 0.25 },
{ key: "fifths", weight: 0.20 },
{ key: "tilt", weight: 0.15 },
{ key: "fwhr", weight: 0.10 },
];
const score10 =
dims.reduce((sum, d) => sum + metrics[d.key] * d.weight, 0) * 10;
Because it's deterministic, the same photo always yields the same score — which, for a "rate me" tool, matters a lot for trust. (Vision-LLM scores drift between runs; geometry doesn't.)
Step 4: the part that does cost money
Numbers alone are a bit cold, so the paid tier sends the photo to a vision LLM once and gets back a written reading — archetype, personality notes, and a constructive, non-surgical "glow-up" direction. That's the only path where the image leaves the device, and it's gated behind a credit so the cost is covered. I keep the prompt firmly on the "fun + self-reflection" side and refuse anything that infers race/age or pushes surgery.
Lessons
Push the deterministic, cheap work to the client. It made my free tier genuinely $0 and privacy-preserving instead of a loss leader.
Self-host MediaPipe assets. Saved me from CDN reliability issues and a third-party request on every visit.
Reproducibility builds trust more than a fancier-but-wobbly score.
— upload a selfie and the geometry score comes back in a couple of seconds, all in your browser. Happy to answer questions about the MediaPipe or scoring bits in the comments.
SOCIAL SHARE CARD GENERATOR