Bridesmaid dresses are one color for the whole party β usually final-sale β and the color that flatters the bride can drain every other complexion in the photos. The color is chosen today by eyeballing one retailer model who matches nobody in the party.
OneDress measures each bridesmaid's real skin hex and Fitzpatrick depth, then solves a constrained group-optimization problem: find the single dress color where the least-flattered person is lifted the most (max-of-minimum fairness), and render it photorealistically on every bridesmaid at once.
It's the inverse of a personal-color quiz: not "what's my season?" but "given one garment all N people must wear, which single color harms the group least?" β a question single-user analysis can't answer, because the group's answer is never any individual's top pick.
The sponsor SDK is the engine, not decoration. All five load-bearing endpoints are
proven end-to-end by a live spike (scripts/spike.ts, npm run spike):
| # | Endpoint | Role | Verified result |
|---|---|---|---|
| 1 | skin-tone-analysis |
measured skin hex β the scoring input | e.g. #bb9982 β ITAΒ° 43, hueΒ° 61 |
| 2 | fitzpatrick-scale-analyzer |
Type IβVI depth cross-check | e.g. Type II |
| 3 | face-attr-analysis |
faceShape β earring silhouette |
e.g. Heart |
| 4 | cloth-v3 |
render the winning color on each bridesmaid | render-fidelity ΞE00 median 7.8 (5.5β11.2) |
| 5 | 2d-vto/earring |
chained onto the render (undertone β metal) | gold hoop landed on the render |
Measured cost: a full one-person run is 43 units (skin-tone 20 + fitzpatrick 10 +
face-attr 10 + cloth-v3 2 + earring 1) β independently confirmed by the credit-balance
delta in npm run bench. Analysis is the cost center, so each bridesmaid is measured
once per run and every re-score after that is free (ranking all 24 colorways is pure
local math, zero API cost).
For each (bridesmaid p, colorway c):
flatter(p, c) = 0.50Β·U + 0.30Β·C + 0.20Β·S
U undertone harmony β dress warmth vs skin warmth (from CIELAB hue angle)
C value contrast β |ΞL*| in a flattering band (triangular, not "max distance")
S saturation harmony β enough chroma separation to read as its own color
The group objective is Rawlsian max-of-minimum β optimize the worst individual, not the average:
groupScore(c) = min over bridesmaids p of flatter(p, c)
winner = argmax over colorways c of groupScore(c) β nobody is anyone's worst
by-eye pick = argmax over colorways c of mean_p flatter β how it's chosen today
The by-eye (mean-maximizing) pick is the counterfactual, and the gap between the two
objectives is the whole product. Run on synthetic Fitzpatrick IβVI profiles, they choose
different colorways: maximizing the average picks rust, which is excellent for four
of six and drops the deepest-skin bridesmaid to 38.8/100. The maximin winner,
marigold, lifts her to 65.3 β a 26.5-point swing on one person β with nobody in
the party below 57.8.
Who the objective protects is party-dependent, not structural: it defends whoever the
available palette serves worst, which on some parties is a mid-tone member nobody thinks to
check. We report who it actually turned out to be on each run rather than assuming. The color
math (sRGB β CIELAB D65, ITAΒ°, hue angle, ΞE2000) is fixed physics; the three weights and
transfer-function constants are disclosed, calibratable parameters. Full derivation:
lib/colorway/engine.ts.
graph TD
A["πΈ 2 photos per bridesmaid<br/>face selfie + full-length"] --> B["Next.js API routes<br/>key stays server-side"]
subgraph SKIN["π¬ YouCam Β· Skin AI β 3 endpoints"]
C1["skin-tone-analysis<br/>β measured hex"]
C2["fitzpatrick-scale-analyzer<br/>β Type IβVI"]
C3["face-attr-analysis<br/>β faceShape"]
end
B --> C1
B --> C2
B --> C3
subgraph ENGINE["π¨ Our math β zero API cost"]
D["CIELAB pipeline<br/>ITAΒ° Β· hue angle Β· ΞE2000"]
E["Maximin scoring<br/>24 colorways Γ N bridesmaids"]
F["Winner + by-eye counterfactual"]
end
C1 --> D
C2 --> D
D --> E
E --> F
subgraph VTO["π YouCam Β· Apparel + Jewelry VTO β 2 endpoints"]
G1["cloth-v3<br/>β winner rendered on each bridesmaid"]
G2["2d-vto/earring<br/>β chained onto the render"]
end
F --> G1
C3 --> G2
G1 --> G2
G2 --> H["π Verdict card<br/>lineup Β· guarantee Β· ΞE badge"]
classDef io fill:#2B2430,stroke:#6B5A66,stroke-width:1px,color:#FAF5F7
classDef skin fill:#D98BA3,stroke:#A85F7A,stroke-width:1px,color:#241520
classDef math fill:#9CAF88,stroke:#6F8060,stroke-width:1px,color:#1B2116
classDef vto fill:#B79BD4,stroke:#8468A8,stroke-width:1px,color:#1E1728
classDef verdict fill:#E9C46A,stroke:#B08D3C,stroke-width:2px,color:#2A2210
class A,B io
class C1,C2,C3 skin
class D,E,F math
class G1,G2 vto
class H verdict
style SKIN fill:#D98BA31A,stroke:#A85F7A,stroke-dasharray:4 3
style ENGINE fill:#9CAF881A,stroke:#6F8060,stroke-dasharray:4 3
style VTO fill:#B79BD41A,stroke:#8468A8,stroke-dasharray:4 3
Rose is the sponsor's Skin AI, violet is its VTO, sage is our own math. The colour split is the Topic C argument in one glance: five YouCam endpoints doing the measuring and the rendering, with a deterministic engine in between that turns measurements into a decision.
Each bridesmaid is measured once per run; every re-score after that is pure local maths, so ranking all 24 colorways again costs zero API units. Only a re-render calls out.
| Layer | Technology |
|---|---|
| Frontend | Next.js 15 (App Router), React 19, Tailwind CSS |
| Language | TypeScript (strict) |
| API client | Typed, Zod-validated wrapper (lib/youcam/) β token-bucket rate limit, retry/backoff, bounded polling. The API key stays server-side. |
| Scoring | Pure, deterministic engine (lib/colorway/, lib/color/) β zero network, unit-tested |
| Sponsor API | YouCam API (Perfect Corp) β 5 endpoints across Skin AI + Apparel VTO |
| Testing | Vitest (unit) + Playwright (E2E) + Lighthouse CI |
Every number below is printed by a command in this repo β none are estimates. Where a number isn't measured yet, it says so instead of guessing. The live-API rows need a key; tests and coverage run with no key and no network.
| Metric | Value | Where it comes from |
|---|---|---|
| Unit tests | 77 passing | npm run test |
| Coverage | 100% β statements, branches, functions, lines | npm run test:coverage |
| E2E tests | 16 passing (desktop + mobile) | npm run e2e |
| CI pipeline | 6 stages, parallel, concurrency-guarded | .github/workflows/ci.yml |
| Render fidelity | ΞE00 median 7.8, range 5.5β11.2 (n=1 subject, 9 patches) | npm run bench --yes |
| API cost | 43 units per bridesmaid, measured | npm run spike |
| Live endpoints proven | 5 / 5 green | npm run spike |
| End-to-end latency | 33.4s for one subject; per-stage medians in DEMO.md | npm run bench --yes |
| HTTP requests | 47 for one subject (5 uploads Β· 5 PUTs Β· 5 creates Β· 26 polls Β· 4 credit Β· 2 downloads) | npm run bench --yes |
| True p50 / p95 | not measured β needs ~20 runs (β860 units, more than the grant) | stated, not guessed |
Topic C β Skin AI + Apparel VTO combined. OneDress fuses three Skin AI endpoints
(skin-tone-analysis, fitzpatrick-scale-analyzer, face-attr-analysis) with two
Apparel/Jewelry VTO endpoints (cloth-v3, 2d-vto/earring) into a single decision β the
measured skin values drive the render, they aren't shown side by side. Remove any one and
the flow visibly breaks.
Why it could only be built on YouCam. The whole thesis needs a measured skin value,
not a self-reported season or a swatch a user taps: skin-tone-analysis returns an actual
hex we can push through CIELAB, and fitzpatrick-scale-analyzer gives an independent depth
reading to cross-check it. That is the input the optimizer needs and the reason the output
is defensible. Then the same vendor renders the result on the real person β so the color we
solved for and the color you see on her are the same pipeline, and we can measure the drift
(ΞE) between them.
Honest limitations. Three, stated here rather than left to be discovered:
1. The render drifts from the intended hex. cloth-v3 is a generative try-on, so the
rendered fabric does not land exactly on the target. Measured across 9 patches of one
render: median ΞE00 7.8, ranging 5.5 to 11.2. The median is a modest shift; the top of
that range is not β at ΞE00 11 the fabric reads as a neighbouring colour. We publish the
range rather than the flattering point estimate, and put the reference swatch beside every
render. The decision is made on measured skin values and fixed colour math, not on the
render; the render is how you check the decision.
2. The 24 colorway references are recoloured derivatives of one garment. Openly-licensed
stock has almost no plain single-garment product shots outside a handful of common colours,
so all 24 references in public/refs/colorways/ are one CC-licensed hanging dress remapped
in Lab space to each target hex (full provenance in docs/asset-licences.md).
Holding the garment constant is arguably the right control for a colour reference set β only
the colour varies β but it does mean the ΞE00 of 0.51 between target and reference is
calibration, not luck. It proves the swatches aren't mislabelled and that the LabβsRGBβJPEG
round trip didn't drift them. It is not evidence that a photograph happened to match.
3. The fairness objective only bites when a party spans widely. On a party reaching Fitzpatrick VI, maximin and mean choose different colorways and the gap is 26.5 points. On a narrower real party we measured (ITA 46.6 β β13.3), both objectives chose the same colour and the counterfactual lift was zero. That is the honest behaviour: when no colorway disadvantages anyone, OneDress says so instead of manufacturing a difference.
The group is the unit of purchase. 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 β and every one of them 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 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. That lever belongs to the returnable-apparel extension, not to the flow we built.
- Node.js β₯ 20
- A YouCam API key (see
.env.example)
git clone https://github.com/edycutjong/onedress.git
cd onedress
npm install
cp .env.example .env.local # add your YOUCAM_API_KEY
npm run dev # http://localhost:3000Routes β one deploy, three surfaces:
| Route | What it is | Where it lives |
|---|---|---|
/ |
Landing page | public/landing.html (static, rewritten from /) |
/pitch |
10-slide pitch deck | public/pitch.html (static, rewritten from /pitch) |
/party |
The product β the 7-step flow | app/party/page.tsx |
/api/* |
Party orchestration, scoring, upload, credit | app/api/** |
npm run spike # runs all 5 endpoints live and prints a green summary + unit costThe spike uses local throwaway fixtures (gitignored). No login, no accounts β the app is zero-config by design.
6-stage pipeline: Quality β Security β Build β E2E β Performance β Deploy
npm run ci # format:check + lint + typecheck + tests w/ coverage
npm run test # Vitest unit tests
npm run e2e # Playwright (mobile + desktop, zero-config)
npm run lighthouse # Lighthouse CI (a11y is a hard gate)
make security-scan # npm audit + license check| Layer | Tool | Status |
|---|---|---|
| Code Quality | ESLint + Prettier + TypeScript strict | β |
| Unit Testing | Vitest (77 tests, 100% coverage on all four metrics) | β |
| E2E Testing | Playwright (54 specs: app, marketing routes, demo-mode, responsive) | β |
| Security (SAST) | CodeQL | β |
| Security (SCA) | Dependabot + npm audit | β |
| Secret Scanning | TruffleHog | β |
| Performance | Lighthouse CI | β |
onedress/
βββ app/party/ # Next.js App Router β the product, the 7-step flow
βββ app/api/ # route handlers (party, score, upload, credit)
βββ public/ # landing.html + pitch.html (served at / and /pitch) + assets
βββ lib/
β βββ youcam/ # typed API client (the only place calls are made)
β βββ color/ # CIELAB pipeline: ITAΒ°, hue angle, ΞE2000
β βββ colorway/ # 24 swatches + the maximin scoring engine
β βββ earring/ # faceShape + undertone β silhouette + metal
βββ __tests__/ # 77 unit tests, 100% coverage
βββ e2e/ # Playwright specs
βββ bench/ # reproducible benchmark (dry run by default, zero cost)
βββ scripts/spike.ts # live 5-endpoint proof
βββ docs/ # README assets
βββ .github/ # CI/CD, CodeQL, Dependabot, community health
Live: onedress.edycu.dev β landing Β· /party the app Β· /pitch the deck
Video: posted at submission
Deployment is automated β see docs/deploy.md.
Pre-submission (deadline 2026-08-17).
- Typed YouCam client β file β task β poll, rate limit, retry/backoff
- Live 5-endpoint proof (
npm run spike) β all green, cost measured - CIELAB pipeline + published maximin scoring engine, 77 unit tests at 100% coverage
- Full harness: 6-stage CI, CodeQL, Dependabot, Playwright, Lighthouse
- 7-step interactive UI (Create Β· Measure Β· Score Β· Compare Β· Render Β· Finish Β· Verdict)
- Cached zero-unit demo party β the live URL's default, no key required
-
npm run benchβ call counts, per-stage latency and ΞE distribution, published in DEMO.md - Post-hackathon: real garment catalogue integration, shareable party links
MIT Β© 2026 Edy Cu
Built for the YouCam API Skin AI & Apparel VTO Hackathon. Thank you to Perfect Corp for the YouCam API.