The case study
What "See yourself, made up" is actually proving, in the 90 seconds you have.
This is a live AI makeup try-on prototype, built as an AI product management case study - the demo above is real, on-device, and yours to try. Below is the part a demo alone can't show: why this problem, why this approach, what was measured, what's guarded against, and what's honestly still missing.
- The problem: shade uncertainty is the purchase blocker in online beauty - try-on removes the guess.
- What's measured: a fairness bug in the rendering pipeline, found and fixed, with a number attached (see Evaluation).
- What's guarded: thirteen cataloged failure modes, each with a severity and a specific mitigation, not just a working demo (see Failure modes and Guardrails).
01 - The problem
You can't tell how a shade will look on you until it's already on your face.
Online beauty has one purchase blocker that recurs across every retailer: a swatch on a screen doesn't tell you how a shade reads against your own skin.
The two existing fixes are both weak. Self-reported skin-tone quizzes ask people to categorize something they usually can't judge accurately. Static "shade finder" charts assume everyone's screen and lighting render color the same way. Both push the uncertainty back onto the shopper instead of resolving it - and the retailer eats the resulting returns.
This prototype stands in for a retailer's product page or an in-store kiosk: the moment someone is deciding whether to buy, let them see the shade on their actual face, live, before they commit.
02 - Use-case selection
Why try-on, why a rule-based recommender, why on-device - and what would change each call.
Why try-on, not a quiz
A quiz asks someone to self-report something - their undertone, their depth - that most people can't judge accurately about themselves. Try-on replaces a guess with a direct look, using the camera instead of a dropdown.
Why a rule-based recommender on top of an ML tracker, not a trained or generative model
Face tracking is genuinely ML (MediaPipe's Face Landmarker, a 468-point mesh) because that's a hard geometry problem worth a trained model. Shade recommendation, on top of that mesh, is a hand-tuned heuristic - CIELAB hue angle for undertone, luma for depth - deliberately, not by default:
- It's traceable: every recommendation reduces to two numbers a person can inspect, not a black box.
- It needs no labelled training data ("this shade suits this skin") that doesn't exist yet at this project's stage.
- It runs in milliseconds, entirely on-device - which is also what keeps the privacy claim structural rather than a policy promise.
What would change this decision: real recommender-agreement data. If human raters consistently reject the heuristic's picks at a rate a trained model would clearly close, that gap - measured, not assumed - is the evidence that justifies training one.
That test is specified but not yet run (see Evaluation).
Why on-device, not a server-side model
On-device removes per-session inference cost, removes the upload round-trip that would otherwise sit in the critical path of every frame, and means the privacy line in the UI ("on-device only") is true by construction, not a claim that has to be trusted.
03 - How "Style me" reads you
Five sample points, one median, and a hue angle - not a trained model, a transparent one.
Every "Style me" tap takes one fresh reading of your actual skin, live from the camera, and scores the catalog against it. Here's exactly what that reading is - no more, no less.
The reading
The moment you tap "Style me," it samples five fixed points on your face - both cheekbones, both lower cheeks, mid-forehead - from the raw camera frame, never the painted-on makeup.
It takes the median color across those five points, not the average. That's deliberate: one bad patch - a strand of hair, a glare spot, a shadow - can't drag the reading off if the other four agree.
What it's reading for
- Depth - light, medium, or deep - from how bright the sampled skin is.
- Undertone - warm, cool, or neutral - from the hue angle of that color in CIELAB space. The code's own comment is exact about what this is and isn't: "hand-tuned heuristics, depend on camera white balance, not a calibrated model."
- Confidence - if the five sample points disagree with each other past a set threshold, that's flagged as uneven lighting, and every recommendation downstream gets hedged as a "rough guess" instead of stated flatly.
Two guardrails sit in front of all of this: a reading that's too dark, or too washed out by glare, is rejected outright with a toast explaining why - rather than turning bad input into a confident-sounding number.
From a reading to a recommendation
Every shade in the catalog gets scored against your reading: points for a depth match, points for a tone match, partial credit when either side is neutral. Style me applies that scoring to all ten curated looks at once - summing the score across each look's three products - and applies the best-ranked one.
Tap "Another look" and it doesn't re-read your face - it steps to the next look in that same ranked list. One reading, several ranked options to browse, not a fresh guess each time.
04 - Architecture
Nothing but the tracking model itself crosses the network.
The only things that ever cross the network: the MediaPipe model files (loaded from public CDNs) and the page's own static assets. Camera frames, uploaded photos, and the rendered image never leave the browser. Single static page, no build step, no backend.
05 - Evaluation
The fairness bug, measured: a flat 24-32 ΔE band instead of a collapse to 11.
Anyone can say a feature works across skin tones. This is what it looks like to actually check - a defined method, and a result that could have come back bad.
The rendering pipeline blends makeup color onto skin in three passes: hue transfer, pigment coverage, depth. The first version used one multiply pass, which the code's own comment already called out as rejected: "multiply alone only darkens, which mutes bright shades and vanishes on deeper skin."
That was caught by eye - a real but unverifiable finding. This eval puts a number on it.
Method, in plain terms:
- ΔE (Delta E) measures how different two colors look to the eye. Roughly: 1 is barely noticeable, 20+ is obviously different.
- The Monk Skin Tone scale is the 10-point reference Google and others use for fairness testing - MST-1 lightest to MST-10 darkest.
This eval replicates the production color math exactly - the same `color` → `source-over` → `multiply` formulas and alphas `index.html` uses - against all 10 tones and all 16 catalog shades, then measures the ΔE between bare skin and post-makeup color. Synthetically, not on real faces or cameras (see Scope below).
| Skin tone (Monk scale) | Production, 3-pass ΔE | Naive, multiply-only ΔE |
|---|---|---|
| MST-1 (lightest) | 23.89 | 60.94 |
| MST-2 | 24.37 | 59.50 |
| MST-3 | 25.53 | 60.01 |
| MST-4 | 28.21 | 56.49 |
| MST-5 | 31.33 | 49.71 |
| MST-6 | 30.02 | 34.99 |
| MST-7 | 26.31 | 26.03 |
| MST-8 | 26.62 | 19.28 |
| MST-9 | 32.22 | 15.61 |
| MST-10 (darkest) | 31.67 | 11.03 |
The naive multiply-only approach loses ~85% of its visibility from lightest to darkest skin (60.94 → 11.03 ΔE). The shipped 3-pass pipeline holds a flat 23.9-32.2 band across all 10 tones instead - if anything, it trends slightly up on deeper skin rather than down.
It isn't uniformly strong everywhere. The five weakest (tone, shade) pairs are all neutral eyeshadow on mid-to-deep tones:
| Tone | Shade | ΔE |
|---|---|---|
| MST-8 | Eyes / Rose Taupe | 4.12 |
| MST-7 | Eyes / Rose Taupe | 7.09 |
| MST-10 | Eyes / Soft Grey | 9.11 |
| MST-9 | Eyes / Soft Grey | 9.22 |
| MST-9 | Eyes / Rose Taupe | 11.74 |
That's explainable, not random. Rose Taupe and Soft Grey are low-saturation neutrals by design, and on deeper skin their hue sits close to the skin's own - a "your-skin-but-slightly-different" shade will always read lower-contrast than a saturated one, on any tone.
Scope: this measures the rendering pipeline's color math on synthetic swatches - not tracking accuracy or recommender agreement, which are a separate, real-faces question. Naming that boundary precisely is itself part of the point: an evaluation is only credible if you can say exactly what it does and doesn't cover. Full method and data →
06 - Failure modes
What's been found so far, and how it's handled.
| Mode | Severity | Mitigation |
|---|---|---|
| No face detected | High - blocks the flow | Visible no-face hint; feature pauses until the face returns |
| Camera permission denied / unavailable | High | Explicit camera-error state; "Use a photo instead" fallback path |
| Dark / low light | Medium | Lighting-quality check; recommendation shown as a hedged "rough guess" or withheld |
| Glare / overexposure | Medium | Same lighting-quality check |
| Uneven / mixed-temperature light | Medium | Uneven-light flag; confidence downgraded |
| GPU delegate unavailable | Low - degrades, doesn't fail | Automatic fallback to CPU delegate |
| Neutral eye shades (Rose Taupe, Soft Grey) lose contrast on MST 7-10 | Low - shade still applies, just subtler | Not yet implemented; documented as a known limitation |
| Bimodal detection latency on the one Mac tested (~50ms vs. 85-160ms) | Low | Adaptive frame-skipping holds ~28-30fps regardless |
| Lighting preview is room-relative, not physically accurate | Low - cosmetic preview only; skin reading always uses the raw frame | Not yet surfaced in-product; documented in README |
| Eyeshadow / blush placement doesn't model eyelid shape or contours | Low | Accepted approximation at current demo-fidelity bar |
| No internet connection | High - blocks the flow | Face-tracking model files load from public CDNs on first use; no offline fallback yet |
| Insecure context (plain HTTP, not localhost) | Medium | Live camera is blocked by the browser; "Take a sharp photo" and "Use a photo instead" still work |
| WebGL2 unavailable (Clarity noise-reduction pass) | Low - degrades, doesn't fail | Clarity switches itself off; the picture falls back to the unprocessed frame |
13 entries identified through real-device testing and the fairness eval above.
07 - Guardrails & responsible AI
Specific controls, not "safety filters."
Lighting-quality gate
Dark, glare and uneven light are each detected before a recommendation is made; low-confidence reads are hedged as a "rough guess," never presented with false certainty.
On-device only
Video and photo frames never leave the browser. Only the MediaPipe model files, from public CDNs, cross the network - nothing captured from you does.
Graceful degradation
GPU unavailable → automatic CPU fallback. Face lost → visible hint, not a silent freeze. Camera denied → a working non-camera path, not a dead end.
Honest data
The shade catalog and checkout are clearly demo data - generic product names, a labelled demo checkout, no real prices standing in as real ones.
Consent
Every face shown in the demo or its marketing is the builder's own or a consenting volunteer's - no stock photos presented as real users.
Measured, not assumed, fairness
The visibility-parity claim above is a number from a documented method, not a description.
Camera locked to this page
A Permissions-Policy: camera=(self) header stops the camera from being requested if this page is ever embedded elsewhere - the permission can't be borrowed by another site.
Shared links can't be weaponized
A shared look link can only set shades from the real catalog; unrecognized values are ignored. A local-only QR override is honored solely on localhost, so a crafted link on the live site can never redirect where the QR code points.
08 - Latency & unit economics
What's actually been measured, and what hasn't.
| Device | Detection time | Frame rate | Notes |
|---|---|---|---|
| Intel Mac, 6-core, Chrome, 720p | ~46-49ms warm | ~20fps | Bimodal: some runs ~50ms, others 85-160ms; cold start ~125ms |
| Phone, low-end laptop, 2+ more devices | - | - | Not yet measured |
Cost: on-device inference means no per-session model cost and static hosting only. A server-side alternative would need real vendor quotes to compare honestly - that table isn't filled in here, on purpose, rather than publish a plausible-sounding placeholder with no real numbers behind it.
09 - Metric tree
A north star, guardrails, and quality metrics - wired to real events, not a wishlist.
North star
Successful try-ons per visitor - a visitor who starts the camera or uploads a photo and reaches at least one applied look.
Guardrail metrics
Camera error rate, time to first try, frame rate, tracking loss rate - fed by camera_started, camera_error.
Quality metrics
Correction rate, low-confidence rate, no-face rate - fed by shade_select, recommend_look.
21 track() events already fire in production - including occasion_select, deeplink_open, copy_look_link, before_after_created/saved/shared, add_to_bag, checkout_demo - each one feeding directly into the tree above.