Project blogSkinScan

Building SkinScan: a research log of the CV and recommendation pipeline

A step-by-step record of how I built a face-photo pipeline for visible skin concerns, face regions, and product recommendations. Includes the failed experiments.

computer visionMask R-CNNrecommendationresearch
ResultGround truth vs zoom vs tile lesion detection on the same face photo
Figure 1. Serving A/B on 756 held-out lesions. Zoom found 8. Tile found 254 with 91% correct types. Native tiling became the production path.
0.743SA-RPN recall@0.50
70%tile recall vs 4% zoom
0.609StatsRanker pairwise
7recorded dead ends
  1. 01native 1024px tiles
  2. 02SA-RPN HTTP service
  3. 03cross-tile dedupe
  4. 04regions + ITA tone
  5. 05care decision
  6. 06hard eligibility
  7. 07eligible-only ranking

What this project is

SkinScan is a research and learning project. Given a face photo, the system finds visible skin concerns, maps them to face regions, estimates skin tone, and recommends products through reviewed rules.

It is not medical software. The language is cosmetic-concern only. There is no diagnosis.

The production path today is: native 1024px tiles of the photo, a required external SA-RPN HTTP service for lesion identification, MediaPipe face regions, ITA-based tone estimation, then a v3 care decision and product eligibility stack. There is no local-model fallback if the SA-RPN service fails.

This post walks through the work in the order I did it, including the paths that failed.

Step 0. Contracts before models

Before training anything, I locked the data contracts. The recommender needed a fixed vocabulary so it would never chase moving model outputs.

I defined a closed concern list, a closed face-region list, ordinal severity from 0 to 4, and a catalog schema. The CV stages were then built to fill those contracts.

  • Concerns: acne_comedonal, acne_inflammatory, acne_cystic, acne_scarring, hyperpigmentation, dryness
  • Regions: forehead, nose, left_cheek, right_cheek, chin_jaw, perioral
  • Datasets: ACNE04 for detection, Kaggle acne types for classification, FFHQ for negatives, Sephora products and reviews for ranking, self photos for test only

Step 1. Lesion detector (YOLOv8)

I trained a single-class lesion detector on ACNE04. That dataset has 1,457 dermatologist-boxed faces and 18,983 boxes. Training started from COCO weights on a Colab T4.

I began with YOLOv8-nano. Recall on small dense lesions was weak. The model was too small. I upgraded to YOLOv8m. That became decision D-018.

The first check was always visual label review, not metrics. After that I swept confidence thresholds and locked an operating point that favored recall over precision. A missed spot is gone forever. An extra crop can still be rejected later.

  • Locked point: conf 0.07, IoU 0.2, imgsz 1024
  • Result: precision 0.697, recall 0.750, F1 0.722
AttemptFace profile with red boxes marking annotated acne lesions for label review
Figure 2. First step was always eyeballing labels. Metrics came after the boxes looked trustworthy.
ResultGrid of faces comparing green ground-truth boxes and red detector predictions across acne severity levels
Figure 3. Green = ground truth, red = predictions. The locked operating point favored recall so weak crops could still be rejected later.

Step 2a. First classifier attempt, self-labeled crops

I trained a first classifier on detector crops from ACNE04 that I labeled myself. Classes were comedonal, cystic, inflammatory, not_acne, and post_acne_mark.

It did not hold up. The confusion matrix diagonal was weak almost everywhere. Inflammatory recall was 20 of 56. Cystic recall was 10 of 30.

The error audit showed why. At crop scale, fading inflammatory lesions and post-acne marks look nearly the same. Self-labeled concern-level crops were too noisy.

One class did work. Not_acne was the strongest row at 23 of 36. That was the first evidence that a learned reject class was viable. Those negatives came from harvested detector false positives: hair, shadows, fabric, and plain skin.

Failed experimentConfusion matrix for the first self-labeled acne classifier with a weak diagonal
Figure 4. Attempt 1 confusion matrix. The diagonal is weak almost everywhere.
Failed experimentGrid of crop errors confusing inflammatory acne with post-acne marks
Figure 5. Error audit. At crop scale, fading inflammatory lesions and post-acne marks look nearly the same.
AttemptHarvested false-positive crops used as Not_acne negatives including hair shadows and fabric
Figure 6. Not_acne negatives harvested from detector false positives. This class was the one clear win from attempt 1.

Step 2b. Second classifier attempt, Kaggle typed dataset

I switched to the Kaggle acne-type dataset. Five real types: Blackheads, Cyst, Papules, Pustules, Whiteheads. I retrained EfficientNetB0 on a Colab T4.

Again I reviewed labels first. Training used Adam at 1e-5 for 150 epochs with a best-validation checkpoint. Split sizes were train 2778, valid 921, test 918.

Test accuracy was about 92 percent. Macro F1 was 0.92 to 0.93. The diagonal was clean. Papules and Pustules remained the soft spot because both look inflammatory. Whiteheads had the smallest test set but scored highest.

I then wired detector to context crop to classifier. On a self-collected photo with real acne, the detector found lesions and the classifier typed them as pustules. That matched the photo. Sixteen detections, all Pustules, confidence from 0.47 to 1.00.

The remaining worry was structural. A 5-way softmax still cannot say none of these. Weak detector boxes on shadows, pores, or hair get forced onto an acne type. That is what Not_acne was supposed to fix next.

AttemptSample review grid of Kaggle acne-type crops across five lesion classes
Figure 7. Label review on the Kaggle typed dataset before training.
ResultTraining and validation accuracy and loss curves for EfficientNetB0 over 150 epochs
Figure 8. T4 training curves. Best validation accuracy landed near 0.92.
ResultClean five-class confusion matrix for Blackheads Cyst Papules Pustules Whiteheads
Figure 9. Clean diagonal on the typed dataset. Papules and Pustules remain the soft spot.
ResultSelf photo with detector boxes and pustule type predictions overlaid
Figure 10. End-to-end on a self-collected photo. Sixteen detections, all typed as pustules.

Step 3. Adding Not_acne. Right idea, wrong dataset

Not_acne had already worked on the small self-labeled model, so I brought it to the Kaggle model as a sixth class. Design analysis preferred a sixth class over a softmax probability threshold, which is miscalibrated on out-of-distribution crops, and over a two-stage binary gate, which was over-engineered.

Negatives were FFHQ clear-skin detector false positives plus non-lesion ACNE04 regions, harvested through the same crop_with_context transform the pipeline uses.

On paper the 6-class retrain looked perfect. Test accuracy 91.72 percent. Macro F1 0.93. Only 2 of 918 real lesion crops were misrouted to Not_acne. FFHQ clear-skin reject rate was 99.7 percent.

Then the end-to-end run fell apart. That became decision D-025. On real faces the model classified essentially every detector crop as Not_acne, including crops that were unmistakably pustules, at confidence 1.00.

Root cause: a crop-domain confound. The acne positives were 640 by 640 Roboflow mosaic images. The negatives were 224px upscaled pipeline crops. The model learned crop style, not acne. Every dataset-level acceptance gate still passed, because none of them fed real pipeline crops of a known-acne face through the model.

  • Lesson: dataset-level metrics can be perfect while the deployed model is fully broken
  • Fix: reverted weights to the 5-class model
  • New gate: Not_acne share on a known-acne image must stay under 50 percent
Failed experimentReal pustule crops all classified as Not_acne with confidence near 1.00
Figure 11. Failed experiment D-025. Real pustule crops classified Not_acne at confidence 1.00. Dataset metrics had looked perfect.

Step 4. Face regions and skin tone

MediaPipe FaceLandmarker with 468 landmarks assigns each lesion to a face region by point-in-polygon. That became decision D-020.

Skin tone is estimated by ITA in CIELAB over non-lesional forehead and cheek pixels, bucketed into light, medium, or deep. Self-report always overrides the photo estimate. That became decision D-021.

I also wrote a fairness-eval design for disaggregating pooled scalars by Fitzpatrick group. ACNE04 ships no tone labels, so ITA is the estimator for that work.

DecisionFace profile with colored region polygons and lesions labeled by cheek jaw and perioral regions
Figure 12. MediaPipe regions. Each lesion is assigned by point-in-polygon into a closed region vocabulary.
DecisionGreen ITA sampling mask over non-lesional cheek pixels with tone triage metadata
Figure 13. ITA sampling mask. Lesion pixels are excluded. Self-report overrides the photo estimate.

Step 5. Recommendation layer

The recommendation stack went through several versions. The current v3 path is decision, therapy plan, hard eligibility, ranking, composition, then whole-regimen validation.

Triage and therapy disposition are independent. High counts, scarring, or pigment concern may add review language without suppressing active treatment. Raw detector confidence is never treated as a calibrated probability. An uncalibrated nodule signal causes an explicit abstention.

Missing reviewed policy or verified catalog data is represented as deferred or unavailable. The system does not guess. Production ships no clinician-reviewed therapy policy, so active therapy is deferred by default.

Ranking is subordinate to eligibility. Concern-specific outcome evidence, tolerability, evidence completeness, and usable budget come first. Pooled review statistics are only a final tie-break among already eligible equivalents.

Step 5b. Learned ranker lost to a simple baseline

I trained a HistGradientBoosting ranker on about 1.1 million Sephora reviews. Pairwise ordering score was 0.584.

A Bayesian-smoothed rating baseline scored 0.609. Global popularity scored 0.597. The learned model lost the bake-off.

I also tried per-skin-type ranking cells. Those scored 0.606 and 0.596, worse than the pooled baseline. The cells were too sparse.

Seven probes confirmed the structural issue. Product-anonymous features cannot beat per-product memorized stats on this setup. I shipped the StatsRanker champion. That became decision D-022. The learned model never shipped.

MethodROC-AUCPairwise ordering
Learned HistGB0.6590.584
Global popularity0.6720.597
Bayesian-smoothed rating0.6660.609

Step 6. SA-RPN detector

I researched Zhang et al., Learning High-quality Proposals for Acne Detection. SA-RPN adds a spatial-aware RPN with NWD proposal scoring and deformable convolutions on Mask R-CNN R50-FPN.

I replicated it on AcneSCU with MMDetection. 275 clinical faces were tiled into 1024 by 1024 crops, giving 4,680 train and 529 val tiles. Fine-tuning from COCO ran for 15 epochs on a Lightning AI A100.

Unlike the YOLOv8m detector, which was a single lesion class, SA-RPN predicts 10 fine-grained lesion types with per-lesion masks.

Validation mAP jumped from about 0.43 to 0.50 exactly at the epoch-9 learning-rate drop, then plateaued. The model converged. More epochs at that recipe would not help.

  • Caveat: strict-IoU localization is weak. mAP at 0.75 is about 0.08. The next lever is anchor and mask-head tuning, not more training.
  • Caveat: the available dataset mirror lacks patient IDs, so the paper patient-disjoint split cannot be reproduced exactly. The split is image-level.
MetricbboxsegmPaper target
mAP @ 0.500.4990.498AP 0.507
Recall @ 0.500.7430.742AR 0.775
mAP @ [.50:.95]0.1740.169
ResultHeld-out forehead tile with red SA-RPN lesion boxes at confidence 0.5 or higher
Figure 14. SA-RPN predictions on a held-out 1024px tile it never trained on.
ResultValidation mAP by epoch chart for bbox and segmentation metrics over 15 epochs
Figure 15. Validation mAP by epoch. The jump at epoch 9 matches the learning-rate drop, then the curve plateaus.

Step 7. Pipeline A/B: how should SA-RPN see a full photo?

The model trains on 1024px tiles of clinical photos. A full face photo has to be funneled into that input somehow. I A/B tested two funnels on the same served checkpoint against 5 held-out validation images with 756 annotated lesions, scored at IoU at least 0.3.

Zoom funnel: the historical YOLOv8m detector finds acne areas. Each cluster is cropped and upscaled to 1024px. SA-RPN re-detects inside those crops.

Tile funnel: the photo is chunked into native-resolution 1024px tiles with guaranteed minimum overlap. Every tile runs. Seams are deduped client-side.

FunnelRecallPrecisionExact-label accConcern acc
Zoom4% (32/756)52%44%62%
Tile70% (530/756)68%92%95%
DecisionThree-panel comparison of ground truth, zoom funnel, and tile funnel lesion detections
Figure 16. Same photo, same checkpoint. Zoom recall 4%. Tile recall 70%. Locked as D-026.

On one proof image with 282 ground-truth lesions, zoom surfaced 8 and tile surfaced 254 with 91 percent correct types.

Zoom fails twice. YOLO at imgsz 1024 downscales a 3448 by 4600 photo by about 4x, so the gatekeeper forwards almost nothing. Then the upscaled blurry crops cut SA-RPN label accuracy roughly in half versus native pixels.

Decision: native-resolution tiling. Locked as D-026. Production cutover to SA-RPN as the sole default was locked as D-027. The YOLOv8m plus EfficientNetB0 path stays in the repo as a historical evaluation-only reference. The default CLI no longer calls it.

Step 8. Mapping SA-RPN labels to concerns

After tiling and detection, labels map into the locked concern vocabulary. Comedones map to acne_comedonal. Papules and pustules map to acne_inflammatory. Nodules map to acne_cystic. Atrophic and hypertrophic scars map to acne_scarring. Melasma maps to hyperpigmentation. That was the first CV path to emit a non-acne concern.

Nevus and other are never concerns. They surface as visible safety observations, gated by per-label count and confidence thresholds. They are not silently dropped.

Step 9. Still in progress: Stage 2 v2 dataset

The D-025 confound motivated a Stage 2 v2 dataset plan. Auditing the Roboflow training data revealed a second problem. Augmented copies of the same source face leak across train, val, and test. For Blackheads, there were 735 train files but only about 91 source images, and 61 of those appeared in all three splits.

The v2 plan harvests crops by running the deployed detector on AcneSCU faces so train crops match deployment crops. Every label gets human review. Splits are by source face before any augmentation. Seven gates must pass before v2 replaces the shipped 5-class model.

Wrong paths kept on the record

Half the value of the project is the dead ends. I kept them on purpose.

Dead endSymptomRoot causeResolution
YOLOv8-nanoWeak recall on small dense lesionsModel too smallUpgraded to YOLOv8m
Self-labeled classifierWeak diagonal; inflammatory confused with post-acne marksNoisy hand labels; crop too small for concern-level classesSwitched to typed Kaggle dataset
6-class retrain v1Real pustule crops classified Not_acne at confidence 1.00Crop-domain confound between mosaic positives and pipeline-crop negativesReverted to 5-class; added real-pipeline gate
Roboflow split trustSame source face in train and testAugmentation done before splittingv2 splits by source face first
Learned product rankerPairwise 0.584 vs baseline 0.609Product-anonymous features cannot beat memorized product statsShipped StatsRanker
Per-skin-type ranking cellsWorse than pooled baselineCells too sparsePooled stats; cells evidence-only
Zoom funnel into SA-RPNRecall 4% vs tiling 70%YOLO downscales hi-res photos; upscaled crops blur detailNative-res tiling locked

What I take away

Every proxy metric can pass while the real pipeline is broken. That is why each stage now has a visual proof sheet and a real-pipeline acceptance gate, not just held-out accuracy.

Contracts first helped. The recommender never had to chase moving model outputs because the concern and region vocabularies were locked early.

Simpler baselines can beat learned models when the feature setup is wrong for the task. Ranking only after hard eligibility kept recommendations from repairing bad catalog or policy data.

Production identification depends on a required SA-RPN service with no local fallback. Detector-only recall at 0.50 is about 0.743. There is still no single measured end-to-end accuracy number for the full SA-RPN production pipeline. That gap is recorded as D-027.

The work continues on the Stage 2 v2 dataset, localization tuning for SA-RPN, and external release gates that code and synthetic fixtures cannot satisfy alone.