Skip to content
Available for work
Adam
←  All projects

Case study · 2026

Chroma

Public-domain art, discovered by colour and ranked the way the eye sees it.

Visit site (opens in a new tab)
Screenshot of the Chroma interface
Status
Live
Year
2026
Stack
Next.js · TypeScript · Python · FastAPI · SQLite · NumPy · Pillow · Tailwind CSS · GitHub Actions · Vercel
Tests
257 tests (112 unit · 128 backend · 17 e2e)
Data
Art Institute of Chicago Open Access API, synced daily into a read-only SQLite snapshot

Overview

Chroma lets you browse art by colour instead of by keyword. Every work has a five-colour palette extracted from the image itself. Pick a colour and the whole collection re-ranks by how perceptually close each palette is; open a work and you get others that are similar in palette. It is discovery by how things look, on a grid that never jumps as images load.

The hard part

Colour distance is easy to get wrong. Plain RGB distance treats colours the eye sees as close as far apart, and the error is worst on saturated blues. So palettes are clustered with a hand-written k-means (k-means++ seeding, fixed seed for repeatable results) in CIELAB, and every ranking uses a vectorised CIEDE2000, tested against a published reference table.

SQLite cannot express CIEDE2000, so the search holds every palette as NumPy arrays and scores the collection in a single vectorised pass. A swatch that covers only a sliver of the canvas is penalised, and the UI shows the true distance and the coverage separately rather than passing the penalised score off as a distance.

Highlights

  • Similarity, precomputed. A batch step builds a "similar in palette" graph, each work's 24 nearest neighbours by a two-way, coverage-weighted palette distance. Nothing heavy runs on a page view.
  • Zero layout shift, tested. Image sizes are stored at sync time and the masonry grid is CSS columns, not JavaScript measurement. A Playwright test watches for layout shift and fails on any.
  • A sync that cannot run away. Every upstream request spends from a per-run budget, runs are gated by freshness and a database lock, and a GitHub Action refreshes the collection daily.
  • Built around a blocked source. The museum's firewall rejects datacenter traffic, so images are stored as WebP at two sizes instead of proxied, a decision verified against production rather than assumed.
  • 257 tests across the Python service, the frontend, and end to end.