Inspiration

I had acne growing up, and I did what everyone with acne does: I tried a lot of products. The part I remember is not any single one of them, it is never knowing which was working. You change three things at once, something eventually gets better or it doesn't, and you carry the whole routine forward because you cannot tell which part earned its place. So you keep buying.

It is under control now, which is exactly why I can see the problem clearly. I am using a face wash every morning that advertises 24-hour moisturisation, and I cannot tell you whether it does anything, which is the same not-knowing I had at seventeen with lower stakes and the same shrug. The average woman is still holding on to four products that did not work, spends around $170 a year trying to find ones that do, and one in nine have been through ten or more failures (AmLactin / OnePoll, 2,000 American women aged 35+, February 2022). I was that one in nine for years, and the reason was never that the products were all bad. It was that nothing could measure whether they worked.

The same survey has the detail that bothers me most as an engineer: a product gets about three weeks to prove itself before people decide. Almost nothing in skincare acts that fast. Retinoids need four weeks to begin and twelve to assess. So the standard consumer verdict is being reached before the evidence could physically exist, which is a measurement problem wearing the costume of a willpower problem.

The measurement already exists. YouCam's Skin Analysis API scores sixteen skin outputs from a photograph and it is a good instrument. So I started building the obvious thing, a tracker that watches those scores over time.

Then I checked whether the scores were stable enough to track. They were not, and that turned into the project.

What it does

Assay answers one question: is this product doing anything on my face?

Before it reports anything about your skin, it measures how much the reading moves when your skin has not changed, and turns that into a threshold. A change is only called real when it clears that threshold.

The output is a verdict in one of six states:

  • Working, the change clears the noise floor and the trend across sessions agrees
  • Getting worse, it clears the floor in the wrong direction, reported early because an adverse reaction shows up fast
  • Expected flare, worse, but inside the window where that active is known to purge
  • Not working, flat, past the point where the ingredient should have done something, with enough data to have caught it
  • No evidence yet, flat, but the study cannot yet resolve an effect this size, with the number of further sessions required
  • Cannot measure, the concern is pinned against the end of the scale, or its error is wider than any effect it could show

Separating not working from no evidence yet is the part almost nothing else does. Telling someone their retinoid failed at three weeks is not a cautious result, it is a wrong one, because retinoids do not remodel anything on that timescale. Assay carries the onset window for each active ingredient and refuses to render a verdict before it.

Two of those six states are what the live study currently shows, because four days on one face is what the data honestly supports: getting worse on texture and blemishes, no evidence yet on the other four. The remaining states are implemented and unit tested, and the method page works through each with the numbers that would trigger it, but I am not going to claim a demonstration the study has not earned.

How I built it

On the "not a wrapper" question, with the numbers. 618 of roughly 6,900 lines of source talk to the API. Against that sit about 2,000 lines of domain logic: 1,206 for the statistics, 442 for the concern and active-ingredient catalogues, 339 for the study store. Roughly three lines of measurement reasoning for every line of API plumbing, plus 1,426 lines of tests, none of which touch the network.

The reason for that ratio is that the API answers a different question from the one a shopper has. It returns a score. It cannot tell you whether that score is stable enough to compare against last week's, and that is the entire decision. Answering it needed things no endpoint provides: a determinism check to prove the model returns identical output for identical bytes, an error budget decomposing the remaining variation by cause, a standard error of measurement and minimal detectable change computed per user, onset windows per active ingredient so a verdict is refused before an ingredient could physically have worked, and saturation detection so a score pinned at the top of its range is reported as unmeasurable rather than as perfect precision.

The clearest evidence is API_FINDINGS.md: fourteen documented behaviours of the Skin Analysis API, several absent from or contradicting the published docs. That file is a byproduct of using the API as an instrument and then characterising the instrument. It is not something a wrapper produces.

Next.js and TypeScript on the front, with the YouCam Skin Analysis API as the measurement instrument. Every frame is normalised to a fixed size, a fixed JPEG quality and a fixed crop before it is submitted, because all three of those turned out to move the score.

The statistics are implemented directly rather than pulled from a package: the log gamma and incomplete beta functions, Student's t, the standard error of measurement, the minimal detectable change, an ordinary least squares trend with inference on the slope, and a power projection that answers "how many more sessions". The whole claim of the project is that the numbers are defensible, so a reviewer should be able to read the implementation and check it against a table. Every expected value in the test suite is hand computed from the definition or taken from a published table, never from a previous run of the code.

92 tests, none of which touch the network or spend an API unit.

Before trusting any of it I characterised the instrument. Three experiments on a synthetic face generated with YouCam's own image generator, so no real person had to be scored to produce those numbers:

Source of variation acne moisture texture pore
The model itself, byte identical input 0.00 0.00 0.00 0.00
JPEG quality, q80 to q96 2.91 0.35 0.12 2.55
Brightness, plus or minus 8% 4.85 3.87 1.96 1.52
Cropping the same photograph differently 5.81

The model is perfectly deterministic, which matters more than it sounds: it means every point of spread between two of your frames is capture variation rather than the model changing its mind, and that is what licenses the entire method.

Everything else is capture. Re-saving the same photograph at a different compression level moves the blemish score by 2.91 points. Standing slightly closer to the camera moves texture by 5.81. A realistic four week treatment effect is about five points.

Challenges I ran into

A frame that satisfies every documented limit can hang forever. A 2048px, 5.27 MB image is within the published 4096px and 10 MB limits. It was accepted, charged 12 units, and then never completed, still running after four minutes of polling. The same face at 1024px returned in four seconds. Every frame is now normalised before upload.

The response envelope key is not consistent. The docs only ever show data, but credit/feature-cost returns result and client/credit returns results with an array of separately expiring grants. Reading data yields undefined silently rather than erroring, so the balance check was quietly broken until I tested it.

feature-cost does not list Skin Analysis at all. It returns twenty image editing and hair SKUs, so it cannot be used to budget a skin study. I established the cost empirically instead: twelve units for a six concern call.

Face size is a fraction, not a pixel count. error_src_face_too_small at full frame 1536px, but success at a 70% crop and only 1024px, with a smaller face in absolute pixels. Upscaling never fixes it. Cropping does.

My own method was wrong at first. I estimated the noise floor from frames taken seconds apart without touching the camera. Those frames cannot see the error you add by setting the camera back up tomorrow, and since cropping alone moves texture by 5.81 points, repositioning is the dominant error. The floor is now estimated between sessions, and it takes whichever error is larger, never the smaller.

Accomplishments that I am proud of

Catching the trap in a perfect score. On a face with no visible redness the API returned exactly 100.00 on every variant, including a full illumination sweep. Read naively that is a noise floor of zero, the most trustworthy metric on the panel. It is the opposite: a reading pinned to the top of its range cannot move upward, so it can never show improvement, and its zero variance is a ceiling artefact. Reporting a beautifully tight error bar for a measurement that is not measuring anything would have been the single most misleading thing this app could do. Assay detects it and declines.

And the number that came out of the real study. Three calibration sessions from one continuous recording of me sitting still: radiance fell 9.54 points across fifteen minutes, monotonically, on skin that by construction did not change. The afternoon light through the window faded and the score followed it. That is the entire argument for the project, measured on a real face rather than asserted.

Those same fifteen minutes give the cleanest test I can offer, because skin cannot change in fifteen minutes and therefore there is nothing real in that data to find. A tracker that shows you a score and compares it to last time would have reported ten of the twelve session-to-session comparisons as a change in my skin, the largest a 17.0 point swing in redness. Every one would have been false. Assay reported none of them. That is not the tool being cautious, it is the tool having measured what this instrument does when nothing happens.

Then the study itself returned a verdict I did not want. Four sessions on a Dove Men+Care Extra Fresh Body & Face Wash, and texture came back at −7.5 against a floor of ±3.1, with blemishes at −6.9 against ±6.4. Both read getting worse, at moderate confidence.

The hydration result is the one worth sitting with, because it is the claim the product actually makes. The label promises 24-hour moisturisation, so I assessed it on the timescale it set for itself rather than one I chose afterwards: the humectant window in the catalogue opens at day 1. Moisture came back at −2.1 against a floor of ±2.9, which is no evidence yet. That is not a finding that the product fails, and Assay does not say so. It is the honest and much less satisfying statement that four sessions on one face cannot resolve an effect this size, and that anyone claiming otherwise from the same data would be reading noise. A rinse-off cleanser is a weak intervention by construction, which is recorded in the study file rather than discovered later.

That is the shape of the commercial problem underneath this project. Advertised claims are stated in absolute terms, on timescales the seller picks, and the measurement that would confirm or refute them is not something a buyer has ever had. Assay is the smallest working version of giving them one.

I am the person who had acne growing up and built this to find out what works, and the first thing it told me was that something I bought was not helping. I would rather know, and shipping that result unedited is the only version of this project worth entering.

What I learned

That the interesting problem was one layer below where I started. I set out to build a tracker and found that the thing worth building was the error model underneath it, because without one a tracker is mostly reporting the weather.

Also that a good instrument and a usable measurement are different things. The YouCam scores are consistent and the model is deterministic. What is not stable is the photograph, and almost all of the engineering here is about holding the photograph still.

What is next for Assay

The same engine aggregated across subjects is a claims panel. A contract research lab charges $25,000 to $80,000 for a skincare efficacy study; the machinery to run one for a few hundred dollars is already here and only needs more people pointing cameras at themselves. That maps onto the FTC's "competent and reliable scientific evidence" standard for advertising claims, which is a budget line an order of magnitude larger than the marketing spend that currently funds virtual try-on integrations.

Nearer term, two things, both of which come straight out of the measurements rather than a feature wishlist.

Live stability detection during capture. The error budget says movement is the largest term in it: re-cropping the same photograph moves texture by 5.81 points, against a realistic four week treatment effect of about five. Assay already holds capture until the light is usable, and the same idea applies to the larger error source, which is holding it until the subject is actually still. Frame to frame differencing would let the capture screen say hold still and then steady, so the error is prevented at the source instead of measured afterwards and widening everybody's floor. I did not ship it for this submission because the capture path was the one thing that had to work on the morning of the final study session, and a working measurement was worth more than a better-guided one.

Establishing a real minimal clinically important difference for the scale, through anchor based studies. The five point threshold Assay uses is currently a product decision rather than a clinical constant, and it is labelled as such in the app.

Built With

  • ai-image-generator
  • computer-vision
  • ffmpeg
  • next.js
  • node.js
  • perfect-corp
  • react
  • rest-api
  • sharp
  • skin-analysis-api
  • statistics
  • tailwindcss
  • typescript
  • vercel
  • vitest
  • youcam-api
Share this project:

Updates