---
title: "What 5,000 images taught me about curation"
description: "More data made the model worse in a way no automatic metric could see. The seed adapters behind AuraX-V1 ended up trained on 5,000 images a person had looked at one by one. Here is what the filter caught, and what it kept missing."
date: "February 25, 2025"
url: "https://himanshuat.com/blogs/what-5000-images-taught-me-about-curation"
---
# What 5,000 images taught me about curation

When early model checkpoints fell short, I scraped larger batches of fashion imagery. Several weeks later, the model produced worse results.

While automatic metrics showed improvements, the model began generating images that e-commerce brand clients refused to use on catalog pages.

> Scaling an uncurated scrape amplifies the distribution you already have. The garments we needed were the ones the internet had least of.

Quantity alone reinforces majority patterns. Every extra batch widened the existing skew instead of covering the rare garment types the model was failing on.

Editorial fashion photography online emphasizes heavy directional lighting, deep shadows, and motion blur. In contrast, e-commerce catalog photography requires flat, balanced illumination, clear fabric legibility, neutral backgrounds, and poses focused on the apparel.

Training on dramatic editorial shots and prompting for catalog outputs produces moody lighting with slightly lighter backdrops.

### Three-stage curation architecture

To ensure training data aligned with commercial requirements, we built a three-stage filtering pipeline:

1. **Stage 1 (Technical Gate):** Deterministic checks on resolution, aspect ratio, Laplacian sharpness variance, and blown highlight clipping.
2. **Stage 2 (Brand Scorer):** Pairwise preference classifier scoring commercial fitness.
3. **Stage 3 (Human Review):** Direct visual verification of all surviving candidate samples.

```mermaid
flowchart TD
  A[candidates] --> B{technical gate}
  B -->|fail| X[dropped]
  B -->|pass| C{brand score}
  C -->|fail| X
  C -->|pass| D{human pass}
  D -->|fail| X
  D -->|pass| E[corpus]
```

Cheapest check first. Nothing expensive, and no human, looks at an image a resolution test could have dropped.

---

### Step 1: Deterministic technical filtering before aesthetic evaluation

The initial stage discards technically flawed files without evaluating subjective composition.

```python
# filter_public_images.py
"""First-stage technical filter over candidate training images.

Applies deterministic physical checks to ensure candidate images
contain adequate pixel density and dynamic range.
"""
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

import cv2
import numpy as np

MIN_SHORT_SIDE = 768        # minimum resolution to retain textile weave details
SHARPNESS_FLOOR = 120.0     # variance of Laplacian on luma channel
ASPECT_RANGE = (0.55, 1.45) # portrait through square; discards wide banners
MAX_CLIP_FRAC = 0.02        # maximum allowable fraction of blown-out white pixels


@dataclass(frozen=True)
class Verdict:
    """Outcome of the technical gate for one candidate image."""

    path: Path
    keep: bool
    reason: str


def inspect(path: Path) -> Verdict:
    """Apply deterministic checks to a single candidate image."""
    img = cv2.imread(str(path))
    if img is None:
        return Verdict(path, False, "unreadable")

    h, w = img.shape[:2]
    if min(h, w) < MIN_SHORT_SIDE:
        return Verdict(path, False, "resolution")

    aspect = w / h
    if not ASPECT_RANGE[0] <= aspect <= ASPECT_RANGE[1]:
        return Verdict(path, False, "aspect")

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    if cv2.Laplacian(gray, cv2.CV_64F).var() < SHARPNESS_FLOOR:
        return Verdict(path, False, "sharpness")

    if float((gray >= 250).sum()) / gray.size > MAX_CLIP_FRAC:
        return Verdict(path, False, "blown_highlights")

    return Verdict(path, True, "ok")


def run(root: Path) -> Counter:
    """Scan directory and log filter decisions."""
    tally: Counter = Counter()
    for path in sorted(root.rglob("*.jpg")):
        verdict = inspect(path)
        tally[verdict.reason] += 1
        if verdict.keep:
            print(path)
    for reason, n in tally.most_common():
        print(f"{reason:>18}: {n}", file=__import__("sys").stderr)
    return tally
```

`ASPECT_RANGE` filters out wide editorial banners and multi-model lookbooks, isolating single-model framing.

`MAX_CLIP_FRAC` removes overexposed photographs where highlights are blown to pure white (255). Training on clipped highlights teaches the model that highlights have zero textile detail, making silk and satin render as flat paper.

---

### Step 2: Structuring datasets by material behavior rather than SKU categories

We organized our seed adapter dataset by textile behavior rather than standard e-commerce retail categories:

| Material | Key Structural Behavior | Critical Synthesis Challenge |
|---|---|---|
| Denim | Heavy creasing, visible twill weave | Weave dissolving upon zoom |
| Silk | Fluid drape, dynamic specular highlights | Highlights rendering as flat painted gradients |
| Leather | Structured sheen, moderate stiffness | Sheen appearing uniform or synthetic |

A denim jacket and a leather jacket require different physical priors, despite sharing the "jacket" SKU label.

```yaml
# seed_corpus.yaml
# Manifest for the 5,000-image curated corpus behind seed adapters.

technical_gate:
  min_short_side: 768
  sharpness_floor: 120.0
  aspect_range: [0.55, 1.45]
  max_clip_frac: 0.02

materials:
  - denim
  - silk
  - leather

policy:
  balance_by: material
  max_share_single_source: 0.15   # prevents a single studio setup from dominating
  require_full_garment_visible: true
  reject_if_face_occluded: false  # face preservation is handled elsewhere
  duplicate_check: perceptual_hash

review:
  stage_1: technical_gate
  stage_2: brand_aesthetic_score
  stage_3: human_pass          # human verification of all survivors
```

Setting `max_share_single_source: 0.15` ensures that no single photographer's specific lighting rig biases the adapter weights.

---

### Step 3: Training aesthetic classifiers on commercial preferences

General aesthetic classifiers trained on public photography rankings systematically favor crushed blacks, heavy vignetting, and dramatic lighting.

To align with commercial catalog requirements, we trained a lightweight ranking head over frozen image embeddings using in-house pairwise comparisons ("Which of these two images better fits a product catalog?"):

```python
# train_brand_aesthetic.py
"""Train brand aesthetic ranking head on pairwise preference labels.

Learns a scalar rating matching human pairwise choices using a margin ranking loss.
"""
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset

EMBED_DIM = 1024
HIDDEN = 256
LR = 1e-4
EPOCHS = 20
MARGIN = 0.2


class PreferencePairs(Dataset):
    """Dataset of precomputed image embeddings for (preferred, rejected) pairs."""

    def __init__(self, pairs: list[tuple[torch.Tensor, torch.Tensor]]):
        self.pairs = pairs

    def __len__(self) -> int:
        return len(self.pairs)

    def __getitem__(self, i: int):
        return self.pairs[i]


class AestheticHead(nn.Module):
    """Maps frozen visual embeddings to a commercial fitness scalar."""

    def __init__(self, dim: int = EMBED_DIM, hidden: int = HIDDEN):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(dim, hidden),
            nn.GELU(),
            nn.Dropout(0.1),
            nn.Linear(hidden, hidden // 2),
            nn.GELU(),
            nn.Linear(hidden // 2, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x).squeeze(-1)


def train(pairs: PreferencePairs) -> AestheticHead:
    """Train aesthetic head with MarginRankingLoss."""
    head = AestheticHead().cuda()
    opt = torch.optim.AdamW(head.parameters(), lr=LR)
    loader = DataLoader(pairs, batch_size=64, shuffle=True)
    target = torch.ones(1).cuda()
    criterion = nn.MarginRankingLoss(margin=MARGIN)

    for epoch in range(EPOCHS):
        running = 0.0
        for better, worse in loader:
            s_better = head(better.cuda())
            s_worse = head(worse.cuda())
            loss = criterion(s_better, s_worse, target.expand_as(s_better))
            opt.zero_grad()
            loss.backward()
            opt.step()
            running += loss.item()
        print(f"epoch {epoch:02d} mean_loss={running / max(len(loader), 1):.4f}")

    return head
```

---

### Step 4: Sizing dataset scale to human verification bandwidth

Every image in the final 5,000-sample corpus underwent direct visual verification.

Five thousand was not a number we picked from a paper. It was the most images the team could look at one by one and still be looking properly.

---

### Failure modes and edge cases

1. **Reviewer workflow leakage into training labels:** Early dataset versions accidentally learned that manually cropped images were preferred simply because the team had taken the time to crop images they already liked.
2. **Monolithic balancing dimensions:** Balancing strictly by textile material left skin tones and body types uncurated, requiring secondary balancing passes.
3. **Distribution ceilings:** Curation ensures fidelity within selected distributions, but cannot generalize to unrepresented styles without collecting new data.

---

### When this is the wrong choice

- **The base model already renders your garments.** Plain t-shirts and hoodies come out of foundation priors fine. Three filter stages and a hand-labeled preference set buy you nothing on a distribution the model was already trained on.
- **You want the editorial look.** The whole brand scorer exists to push against crushed blacks, vignetting, and dramatic lighting. If that is the output you are after, the off-the-shelf aesthetic classifiers are already tuned for it and this pipeline is filtering out exactly what you want.
- **The corpus has to be six figures.** Stage 3 is a person looking at every surviving image, and that is what caps this approach at 5,000. Past roughly the volume your team can actually inspect, the human gate is a bottleneck pretending to be a filter, and you need automated verification instead.

---

Source: https://himanshuat.com/blogs/what-5000-images-taught-me-about-curation
