DEV Community

Cover image for I built an AI face rating app that shows its work: MediaPipe in the browser, an LLM on the server
chao xue
chao xue

Posted on

I built an AI face rating app that shows its work: MediaPipe in the browser, an LLM on the server

A lot of young people want to know how attractive they are, so they ask Gemini or ChatGPT to "rate my face from 1 to 10". Ask twice and you often get two different numbers, and neither answer shows how the number was reached. So I built a more methodical rating tool, where the score comes with evidence you can check yourself.

The result is PSL Rating, a small face analysis app. You upload one selfie and get a tier on the PSL scale, a 1–10 score, and measurement lines drawn on your photo.

Measure first, then judge

Measurements run in the browser. I use MediaPipe's Face Landmarker (@mediapipe/tasks-vision), which returns 478 landmarks per face, including iris centers. From those points the client computes facial thirds, eye spacing, canthal tilt, jaw width and symmetry, then draws the lines on a canvas over the photo. None of this needs a server, and it's deterministic: the same photo always gives the same lines.

Measurement lines drawn in the browser on a sample AI-generated face

The rating comes from an LLM with a carefully tuned prompt. The photo, a text summary of the measurements and a fixed rubric go to a vision model (Gemini, through a third-party API). The model returns structured JSON, which I validate with Zod before anything reaches the UI. If a field is missing or out of range, that section is dropped instead of invalidating the whole report.

The prompt matters a lot. When the model sees "eye spacing ratio 1.04, canthal tilt +4.1°" next to the image, its comments stay much closer to what the overlay actually shows.

The canthal tilt bug I almost shipped

Canthal tilt comes up constantly in face rating discussions. It's the angle of the line from the inner corner of the eye to the outer corner. The most direct way to compute it is:

  1. Straighten the face so the eyes are level.
  2. Measure the angle of each eye.

But that's wrong. If you level the face using the eye line, you rotate away the exact tilt you're trying to measure, and everyone comes out near 0°.

The fix is to correct head roll with a line that doesn't go through the eyes. I use the facial midline, from the top of the forehead (landmark 10) to the chin (landmark 152):

// Roll of the face itself: how far the midline (forehead -> chin) leans from vertical.
// Don't use the eye line here, or you cancel out the real canthal tilt.
export function rollRad(px: Pt[]): number {
  const t = px[LM.FOREHEAD_TOP]; // 10
  const c = px[LM.CHIN];         // 152
  return Math.atan2(c.x - t.x, c.y - t.y);
}

export function canthalTilt(P: Pt[]) {
  // P = pixel landmarks already rotated so the midline is vertical
  const tilt = (inner: number, outer: number) => {
    const dx = Math.abs(P[outer].x - P[inner].x);
    // y grows downward, so inner.y - outer.y > 0 means the outer corner is higher
    return Math.atan2(P[inner].y - P[outer].y, dx) * (180 / Math.PI);
  };
  const right = tilt(133, 33);
  const left = tilt(362, 263);
  return { left, right, avg: (left + right) / 2 };
}
Enter fullscreen mode Exit fullscreen mode

Two more things to watch out for:

  • Convert normalized coordinates to pixels before measuring angles. MediaPipe returns x and y in 0–1, scaled by width and height separately. On a non-square image, angles computed on normalized coordinates are skewed.
  • Corner landmarks are noisy. They wobble by a pixel or two between frames. On a small image that's about ±1°, so the UI rounds and labels small values as "neutral" instead of pretending 0.6° means something.

If you just want to try this part, there's a standalone canthal tilt calculator that runs entirely in the browser.

Photo quality moves the score more than the face does

Early testing showed the same person getting very different results from different selfies. The cause was the photo angle: the camera below eye level, a wide-angle lens too close to the face, overhead lighting, a tilted head.

So before rating, the client runs a quick quality check (face size in the frame, head turn and tilt estimated from landmarks, brightness), and the AI also returns a short "photo check" with up to three issues and three retake steps. If the verdict is "retake", the result page says so at the top. That one card probably did more for fairness than any prompt change.

For the live camera, I first built an auto-capture countdown. It didn't work well: the damn countdown kept restarting. Now it's a large oval guide plus a shutter button the user presses themselves.

Privacy as an architecture decision

Face photos are sensitive, so the design principle is to not keep them:

  • The photo is sent for the rating request and never written to storage. The database keeps the report's text and numbers only.
  • The full report's AI style previews (for example the same face with a different haircut, one change per image) are kept in the user's own browser via IndexedDB for 30 days and cleared on logout.
  • It's 18+ only. Every photo gets an AI age check, and generated previews go through a moderation pass before they're shown. Of course the model gets it wrong sometimes. When I tested with a photo of my cousin, it decided she was under 18. She didn't seem too upset about it.

AI style previews, one change per image

The trade-off is that a user who clears their browser loses the preview images. For this kind of data, I think that's the right call.

Privacy and safety summary

Stack

  • Next.js 16 deployed to Cloudflare Workers with OpenNext
  • D1 (SQLite) for users, credits and reports; KV for small cached values
  • Google sign-in, one free rating per account, then paid reports
  • Vitest for tests, with an in-memory store and a node:sqlite store so the D1 SQL runs in CI

What I'd do differently

  • I'd write the measurement code with pixel coordinates and roll correction from the start, with unit tests on synthetic landmark sets. Most of my early bugs were coordinate-system bugs.
  • I'd build the photo check before tuning prompts. Bad input made the prompt work look worse than it was.
  • I'd be more careful with wording. A face score can easily read as a judgment of someone's worth. The app calls ratings AI estimates for fun and self-improvement, and every suggestion is tagged by effort (today, weeks, months, structural) so people can see what's realistic to change.

If you've built something with MediaPipe or vision LLMs, I'd love to hear how you handled noisy landmarks or inconsistent model output. Feedback on the app is welcome too: pslrating.me.

Top comments (0)