Why I Built This
A friend told me his face shape was better than mine.
I'm not going to argue with him — I have no data. So I went looking for one of those "what's your face shape" tools. Every single one wanted me to upload a photo of my face. To a server. Run by someone I don't know.
Look, I'm a developer. I know what happens to uploaded images. I've written the code that stores them. I did not want a photo of my own face sitting in someone's bucket with a Date.now() filename and an ACL I can't see.
So I built the version I wanted: one that measures your face and never uploads it. Not "we delete it after 24 hours" — the photo literally never leaves the browser tab. It goes into a canvas, MediaPipe reads it locally, and that's the end of it.
It's called FaceRuler. It's free. There's no sign-up — that part wasn't a growth decision, I was just too lazy to build auth for a tool that doesn't need an account.
The Stack
- Next.js 16 (App Router)
- MediaPipe FaceLandmarker — 478 landmarks, compiled to WASM; the model is ~3.6 MB and both it and the WASM runtime are served from my own origin
- Cloudflare Workers via OpenNext, D1 for the few tables I actually need
- A hosted checkout for the paid deep report (the free report is the whole measurement, not a teaser)
The part that matters: there is no server-side inference. Nothing to opt out of, nothing to trust me about. The network tab stays quiet.
The scoring is just math (that's the point)
Here's the decision that surprised people most: there is no LLM anywhere in the scoring.
I could have asked a model to "rate this face". I didn't, for two reasons. One, it would mean uploading the photo — dead on arrival. Two, a score you can't reproduce is a score you can't argue with, and people who use face-rating tools argue.
So it's geometry. Every sub-metric is scored with a Gaussian falloff around a reference target:
export const targetScore = (value: number, target: number, tol: number): number => {
const z = (value - target) / tol;
return 100 * Math.exp(-0.5 * z * z);
};
Eight sub-metrics (symmetry, facial thirds, FWHR, canthal tilt, jaw ratio, eye spacing, eye aspect, golden ratios) roll up into five weighted dimensions. The weighted overall maps onto the PSL 0–8 scale with a logistic curve anchored so an average face lands at exactly 4.0:
// overall 50 -> exactly 4.0 on the 0-8 scale
export const overallToPsl = (overall: number): number =>
8 / (1 + Math.exp(-(overall - 50) / 13));
Median at 4.0, not 5 — the PSL scale is deliberately stricter than a casual 1–10 rating, and the percentile uses a normal distribution (mean 4.0, SD 0.9).
Because it's all pure functions, the entire engine is unit-tested (12/12 green) and "recalibrate" is a one-line change instead of a retrain. Same landmarks in, same report out, every single time.
Two things that were harder than they look
MediaPipe coordinates will quietly shrink your face. The landmarks come back normalized: x divided by image width, y divided by image height. Compute a height/width ratio naively and a 3:4 portrait reads about 25% "shorter" than the same face on a square canvas — enough to flip the score. You have to scale the y component back into pixel-equivalent space. Nobody warns you about this one.
A tilted photo shouldn't change your score. So before computing anything, I measure the midline angle from forehead-top to chin and rotate every landmark into a roll-corrected frame. Vertical distances — like the FWHR numerator — fall apart without it.
One more that isn't a bug but deserves to be documented: the academic FWHR ratio (1.85–1.9) is measured on physical photographs. In normalized landmark space, the nasion-to-upper-lip distance runs ~36% of bizygomatic width, so the numbers aren't comparable at all. I calibrated my own center (2.75) from landmark batches instead of pretending the paper value applied.
The front-end bug that ate a whole evening
React 19's synthetic onChange does not fire for programmatically dispatched change events.
My upload component has to work for real users and for my own automated tests, which inject a File via DataTransfer. The synthetic handler was silent in the injected path. Real users would have been fine — which is exactly why it's dangerous. The test would have lied to me.
Fix: drop the synthetic handler, wire a native addEventListener through a ref. Turns out it's more robust for real users too.
And a rule I now enforce everywhere: never put a ref-dependent DOM node inside a conditional render. My result <canvas> lived inside report && (...). Before the first report, canvasRef.current is null, the draw function's guard returns early, and you get… nothing. No error, no output. I hit this exact bug twice, in two different components, before writing it down as a rule.
What's Next
- Server-side license verification. Right now entitlements live in
localStorage, which means clearing your cache loses your purchase. I say that out loud in the docs instead of hiding it. - A second tool that shares the same credit system.
- Quarterly recalibration from anonymous aggregate data.
Try It
It's live and free, no sign-up: https://faceruler.com
Drop in a photo and you get your face shape, a PSL score, a percentile, and all eight metric readings — with the formulas behind each number instead of just a verdict. Your photo stays on your machine; you can watch the network tab if you don't believe me.
Genuinely curious: if you've shipped something where "we don't upload it" is the entire pitch, how did you get people to believe you? I ended up showing the calculation, but I'm not convinced that's the best answer.
Top comments (0)