-
-
The by-eye pick scores 38.8 on Esi. Ours scores 65.3 — same woman, same measurement, a 26.5-point swing on the party's worst-off.
-
Six measured skin hexes in Fitzpatrick I–VI order, returned by skin-tone-analysis. These are measurements, not a personal-colour quiz.
-
The verdict: Marigold on all six. Nobody below 57.8 — the promise is the party's WORST score, not its average.
-
Max-of-minimum picks Marigold: floor 57.8, mean 65.2. The scoring formula is printed on screen — deliberately not a black box.
-
Same party, objective flipped to "best on average": Rust wins instead. The gap between these two picks is the harm nobody measures.
-
All 24 colorways ranked by group floor. Every card names the bridesmaid that colour serves worst — the losing colours are evidence too.
-
cloth-v3 renders Wine onto seven real people. Person 5 isn't missing: the API rejected her frame with error_pose, so her card says so.
-
Seven people measured live through the YouCam API, one colour on every complexion. Group floor 56.9, party mean 72.9.
-
Seven live readings: skin hex, Fitzpatrick depth, face shape. Nothing here is rounded, averaged or prettified — it is what the API returned.
-
On the real party both objectives pick Wine: these tones span too narrowly to single anyone out. The app says so instead of inventing a gap.
-
Two photos per person: a face selfie the analyzers read, a full-length shot the try-on renders onto. No units spend until you press start.
-
One live run, measured not estimated: 47 HTTP requests, 33.4s, 43 units by credit delta. Reproduce with `npm run bench -- --yes`.
Inspiration
Bridesmaid dresses are one color for the whole party, usually final-sale and non-refundable — and the color that flatters the bride's own skin can drain every other complexion in the photos. Today that color gets picked by eyeballing a single retailer model who matches nobody in the group, and the cost lands on one specific person: whoever is furthest from that model's skin tone. She's washed out in every photo, forever, and nobody in the room can articulate why.
Every beauty AI tool we could find answers "what's my color?" for one person at a time. Nobody was asking the question a bride actually has: given one garment that N different people must all wear, which single color harms the group least? That's not a personal color quiz — it's a constrained group-optimization problem, and it needs real measurements to solve.
What it does
OneDress takes two photos per bridesmaid — a face selfie and a full-length shot — and turns a group argument into a measured decision:
- Measures every bridesmaid's real skin: a literal hex from YouCam's AI Facial Color Tones, an independent Fitzpatrick I–VI depth reading, and face shape.
- Scores all 24 candidate colorways against all six measured skin profiles in CIELAB — 144 pairings — on undertone complement, value contrast, and saturation harmony.
- Optimizes for the worst-off person, not the average. The winner is the colorway whose least-flattered bridesmaid scores highest (max-of-minimum, a Rawlsian objective), so nobody in the party is anyone's worst option.
- Shows the counterfactual. We also compute the by-eye pick — the color that maximizes the average, which is how it's chosen today — and render the most-hurt bridesmaid in both, side by side. On a party spanning Fitzpatrick I–VI, the two objectives choose different colorways: optimizing the average picks rust, which drops the deepest-skin bridesmaid to 38.8/100. OneDress picks marigold, lifting her to 65.3, with nobody in the party below 57.8.
Read that 26.5-point gap as illustrative, not load-bearing. We found a units bug in our
own undertone term four days before the deadline: ~25 of those points are the defect's own
magnitude. We published it rather than patched it — the algebra, the recomputation of both
parties under three candidate corrections, and why no correction ships yet are in
docs/scoring-defect.md, reproducible with npx tsx scripts/scoring-variants.ts. The
claim that survives every correction is structural: the objective you choose changes the
winner, and the maximin winner's floor is never below the mean-maximiser's. That is a
theorem, not a number, and it was always the real thesis.
(Figures printed by lib/colorway/engine.ts on synthetic Fitzpatrick I–VI profiles;
they will be re-run and replaced with the measured demo party's own numbers.)
- Renders it on the actual people. cloth-v3 puts the winning colorway on each bridesmaid's own full-length photo — not a model — and earrings chain onto that render, matched to her face shape and undertone.
The output is one verdict card the bride can send to the group chat: the colorway, the six-up lineup, the guarantee ("nobody below 57.8"), and the counterfactual proof.
How we built it
The sponsor API is the engine, not a garnish. Five YouCam endpoints, each load-bearing — remove any one and the flow visibly breaks:
| Endpoint | Category | What it does here | Remove it and… |
|---|---|---|---|
skin-tone-analysis |
Skin AI | returns the measured skin hex — the scoring input | there is nothing to optimize |
fitzpatrick-scale-analyzer |
Skin AI | independent I–VI depth cross-check | depth banding is unverified |
face-attr-analysis |
Skin AI | faceShape → earring silhouette |
jewelry selection is arbitrary |
cloth-v3 |
Apparel VTO | renders the winner on each bridesmaid | the verdict is a number, not a picture |
2d-vto/earring |
Jewelry VTO | chains onto the cloth-v3 render | the look is a dress, not a look |
Between the measuring and the rendering sits the part that isn't an API call: a deterministic color engine. Measured sRGB hexes go to CIELAB (D65), and we derive ITA° for depth and Lab hue angle for undertone, scoring each pairing as
flatter(p, c) = 0.50·U + 0.30·C + 0.20·S
groupScore(c) = min over bridesmaids p of flatter(p, c)
winner = argmax over colorways c of groupScore(c)
The formula is published in the README and in lib/colorway/engine.ts — the color math
(CIELAB, ITA°, ΔE2000) is fixed physics; the three weights are disclosed, calibratable
parameters. It is deliberately not a black box.
Stack: Next.js 15 (App Router) + React 19 + TypeScript strict + Tailwind. The YouCam
key never leaves the server — all calls go through one typed, Zod-validated client
(lib/youcam/) with a token-bucket rate limiter, retry/backoff, and bounded polling.
Because analysis is the expensive step (40 units per bridesmaid vs 2 to render), each
bridesmaid is measured once per run and every re-score after that is pure local maths —
ranking all 24 colorways again costs zero API units.
Harness: 82 unit tests at 100% coverage on all four metrics (scored modules), 72 Playwright E2E runs across desktop and mobile, a 6-stage CI pipeline (quality → security → build → E2E → performance → deploy gate), CodeQL, Dependabot, TruffleHog secret scanning, and Lighthouse CI with accessibility as a hard gate.
Challenges we ran into
One selfie can't do both jobs. Our first design assumed a single photo per bridesmaid.
It doesn't work: the analyzers want a face filling the frame, and cloth-v3 wants a
head-to-toe shot at ≥1024×768. We found this on day one by spiking the real API instead of
building UI first, and redesigned the capture contract around two explicit uploads with
inline framing guidance — rather than shipping a flow that fails on real users' photos.
Generative try-on doesn't hit an exact hex. cloth-v3 renders beautifully but drifts
from the requested color. We measured it rather than hoping: sample the rendered fabric,
convert to Lab, compute ΔE2000 against the intended swatch — ΔE00 median 7.8, range 5.5–11.2. Instead of
hiding that, we made it a published metric and put the reference swatch beside every
render. The decision is made on measured skin values and fixed color math; the render is
how you check the decision.
The reference set is a controlled derivative, and we say so. Openly-licensed stock has
almost no plain single-garment product shots outside a few common colours, so all 24 colorway
references are one CC-licensed hanging dress remapped in CIELAB to each target hex. Holding the
garment constant is the right control for a colour reference set — only the colour varies — but
it means the ΔE00 of 0.51 between target and reference is calibration, not luck: it proves the
swatches aren't mislabelled, not that a photograph happened to match. Provenance for all 30
images is recorded in docs/asset-licences.md.
Our own fairness claim didn't survive first contact with real data. We measured six real people through the live API. On that party maximin and mean chose the same colorway and the counterfactual lift was zero — because the party's ITA range (46.6 to −13.3) is too narrow for any colour to single anyone out. The divergence we demonstrate needs a party spanning to Fitzpatrick VI. We could have quietly shown only the wider party; instead the app ships both, because a tool that reports "your party is close enough that this doesn't matter" is more trustworthy than one that manufactures a delta every time.
dst_id chaining isn't available. We wanted to chain the earring try-on directly onto
the cloth-v3 output by task id. The API doesn't return a usable dst_id for that hop, so
we verified and shipped the documented fallback — re-upload the render — and only then
wrote "chained onto the render" in our own copy. We didn't claim the chain until it ran.
Unit economics nearly bit us. A full six-bridesmaid run costs ~260 units against a 1,000-unit grant, and it's the analysis that's expensive, not the rendering. That single measurement reshaped the architecture: measure-once-and-cache, request only the face attributes we actually use (staying inside the cheapest tier), and ship a cached demo party so the live demo costs zero units and still works after the grant expires.
Accomplishments that we're proud of
- The counterfactual. Most projects ask you to trust a score. We render the same bridesmaid in the by-eye color and in ours, side by side, so the difference the math found is something you can see. It's the first thing in our gallery for a reason.
- We optimize for the worst-off person. Choosing max-of-minimum over max-of-average is a one-line change in code and a completely different product. And it protects someone specific: whoever the available palette serves worst. That is often not the person anyone would have checked — on our I–VI test party the average-optimizing pick is fine for four of six and quietly costs the deepest-skin bridesmaid 26.5 points. We report who the objective actually protects on each party rather than assuming it's always the same person, because on a different party it isn't.
- We measure instead of asserting. The scoring formula is published in full. The render
colour error is measured against the intended hex and printed, not hidden.
npm run spikereproduces all five endpoints green against the live API in about 25 seconds, and prints the units it spent. - Built for the Fitzpatrick I–VI case, because that's where averaging fails hardest. The engine is exercised across the full I–VI range in the test suite, and the face-card board surfaces each measured hex and depth badge so the spread is visible rather than claimed.
Why a retailer wants this
The group is the unit of purchase, and that's what makes it commercially interesting. A bridal party is roughly six dresses at ~$150 — a ~$900 basket that closes or collapses on a single decision. It's a final-sale category, so returns aren't the retailer's lever; the losses are abandoned carts and stalled group chats, which is exactly what an unresolvable colour argument produces.
Perfect Corp already sells try-on widgets onto this precise product page for 800+ brand partners — but every one of those widgets answers "how does this look on me?". There is no group primitive in the category. OneDress is a net-new SKU for that shelf rather than a competitor to anything the sponsor already ships: one embed that turns six undecided shoppers into one locked order, and differentiates the retailer on inclusivity in a category where getting skin tone wrong is permanently photographed.
One honest boundary: the widely-cited ~24–30% apparel return-rate lever does not apply here, because bridalwear is final-sale. We only claim it for the returnable-apparel extension, not for the flow we built.
What we learned
Spiking the sponsor API before writing any UI was worth every hour. Three of our four
hardest problems — the two-photo capture contract, the render color drift, the missing
dst_id chain — were discovered on day one, when they were cheap redesigns instead of
late rewrites.
We also learned that in a group setting the fairness objective is the product. The average-maximizing color and the maximin color are frequently different colorways, and the gap between them is exactly the harm nobody currently measures. Once we could see that gap, everything else — the counterfactual view, the guarantee line, the ΔE badge — was just a matter of showing it honestly.
What's next
- A blind preference study at larger N, and publishing the agreement rate against our score (target ≥80%) so the harmony model is validated by humans, not asserted.
- Real garment catalogue integration, so the winning colorway maps to dresses a party can actually order in every size in the group.
- Shareable party links, so bridesmaids can add their own photos from their own phones instead of the bride collecting twelve files.
- Extending the same maximin objective past bridesmaids: uniforms, team kits, school colors — any case where one garment is imposed on many different complexions.
Built With
- 2d-vto
- cielab
- cloth-v3
- codeql
- delta-e-2000
- face-attr-analysis
- fitzpatrick-scale-analyzer
- github-actions
- lighthouse-ci
- next.js
- node.js
- perfect-corp
- playwright
- react
- skin-tone-analysis
- tailwindcss
- typescript
- vercel
- vitest
- youcam-api
- zod
Log in or sign up for Devpost to join the conversation.