Plate Curator

Case study, Tooling

8,000+ plates, and still growing. This is what keeps them clean.

3 min read

Admin dashboardHuman-in-the-loopLocal vision model
Plate Curator's pipeline console on Alaska: every template in the state as a card, with its variants nested beneath and the state tools and run controls above.
The pipeline consoleOne state at a time: every template as a card, its variants nested beneath, the run controls above.

State Spotter is only as good as its catalog, and the catalog started as a third-party CSV that went stale the day I imported it. Its 8,000+ rows were too many to fix by hand and too important to hand to a model unsupervised, so I built an admin dashboard that sits between the two.

Vite, React, TypeScript, Express 5, sharp, Ollama, Firestore

8,000+Plates, actively growing
51States reviewed

When in doubt, ask

The model drafts. A person decides

Before writing any code I set one rule: when in doubt, hand the call to a person instead of guessing. Models find images, draft descriptions, propose tags and suggest families, but nothing is accepted automatically. That makes review speed the speed of the whole system, so most of the work went into the board: one state at a time, every plate's health in three coloured icons, and the detail one hover away.

The workflow

Pipeline stages

  1. Find image3 steps

    Locate a usable source image for a plate, and record where it came from.

    Read the vendor source set
    Shortlist candidate images
    Accept a pasted URL as an escape hatch
  2. Clean up the image4 steps

    Crop, replace or upscale a soft plate. Runs on demand, and never overwrites the original.

    Crop in the browser
    Replace by upload
    Upscale 4× and cut out the plate
    Keep-new or revert, side by side
  3. Hydrate4 steps

    Fill in what a player reads: what the plate is, what colour it is, and what is on it. Three passes that check each other.

    Draft the description from a matched Wikipedia article
    Measure plate and text colour, no model involved
    Tag with a local vision model; the bulk pass runs hosted
    Cross-check the tags against the measured colour
  4. Group3 steps

    Propose template and variant families, each with a confidence band. A person applies them, or doesn't.

    Cluster offline and write proposals to a file
    Optional model tiebreak, off by default
    A person applies the grouping, or does not
  5. Order3 steps

    Set the canonical order a player sees, then regenerate the manifest the game bundles.

    Drag to reorder templates; variants inherit their place
    Mark the state done
    Regenerate the game's image manifest

Stage 02, up close

Nothing is overwritten

Every image change takes the same path: write the candidate beside the live asset, move the current one to a timestamped backup, then promote. Undo restores the exact backup, so a bad crop or a bad upscale costs one click.

Four stages of Plate Curator image cleanup for West Virginia's 9-11 Commemorative plate: input, four-times upscale, crop, and transparent cutout.
Four mask stepsInput, upscale 4×, crop, cutout: one real run on West Virginia's 9-11 Commemorative plate.

No app update required

Publish to players

The game ships with a catalog built in, but it also checks a small pointer in Firestore each time it opens. Publishing uploads a new, versioned snapshot of the catalog and moves that pointer, so every player picks up the new plates the next time they launch, without an app-store release. Before anything goes out, the dashboard shows exactly what players will receive: the version bump and every added, changed or removed plate, state by state. The built-in catalog stays as the offline fallback.

The Publish to Players dropdown on the pipeline console: version 11 to 12, 8,325 plates, 0 new, 3,059 modified and 0 removed, a list of changes by state, and Cancel and Publish v12 buttons.
Publish to PlayersThe diff against what players have now, before the pointer moves.

What I'd do differently

I would write the serial write queue correctly the first time. One rejected promise silently stopped every write after it while memory and Firestore agreed the change had landed; only the file on disk disagreed. Anything that can look applied without being applied deserves the careful version up front.