-
Notifications
You must be signed in to change notification settings - Fork 9
Home
This page ties together why Hueclid exists, the color science behind it, and how the system actually works right now. If you only read one section, make it the architecture one, that is the part that is not written down anywhere else yet.
Most palette generators pull five or six dominant colors out of an image and stop there. That is a solved problem from around 2010. What actually matters for a real user interface is that colors never get used alone. Body text sits on the background, but it also sits on card surfaces. A button label sits on the button color. Each of those pairings has its own contrast requirement, and satisfying one can quietly break another.
Hueclid treats this as what it actually is, a constrained optimization problem, not a plain clustering problem. You give it a role graph (background, surface, text, primary, danger, and the contrast each pair needs, measured with APCA rather than the older WCAG 2 ratio), and it searches for a palette that stays faithful to the image's real colors while satisfying every constraint in that graph at once. When no such palette exists, it says so, instead of quietly shipping something that fails one of the edges.
The full pitch, including how this compares to prior work like Colorgorical and Palettailor, lives in the README.
Why RGB distances lie, what CIELAB actually fixes, how delta E00 bends the ruler near blue, why the plain average stops being the right answer once you switch distance functions, and how APCA differs from WCAG, all written out from first principles, no color science background assumed, in math-explained/color-math-explained.html. That page is worth reading before touching anything under backend/app/color/.
Two services, talking over plain HTTP. All of the actual color math runs server side, in Python, nothing client side beyond rendering the result.
flowchart LR
A[Browser: drag or pick an image] -->|POST multipart form| B[FastAPI: /api/v1/extract]
B --> C[Pillow: decode, check type and size]
C --> D[Undo sRGB gamma: decode to linear light]
D --> E[Resize to 512px long edge, in linear light]
E --> F[Linear RGB to XYZ to CIELAB, vectorized]
F --> G[Bin every pixel into a sparse Lab histogram]
G --> H[Weighted k-means over the histogram bins]
H --> I[Lab back to sRGB, per cluster center]
I -->|out of gamut only| J[Oklch chroma reduction, via coloraide]
I --> K[Rank by cluster mass, return JSON]
J --> K
K --> L[Next.js: render ranked, weighted swatches]
Step by step, what actually happens between choosing an image and seeing swatches on screen:
-
Upload. The browser sends the image as multipart form data to
POST /api/v1/extract, with the number of colors wanted as a query parameter. The backend checks the content type against an allowlist (PNG, JPEG, WEBP) and a 15 MB size cap before decoding a single byte. - Decode, then undo the screen's gamma encoding. Screen values are not linear, they are gamma encoded, so the sRGB transfer function gets inverted first, back to physically linear light.
- Resize in linear light, not after. Images get downscaled to a 512 pixel long edge before any histogram work. Doing this resize on the gamma encoded values instead of the linear ones is a common mistake, it measurably darkens and desaturates the result, so the decode has to happen first.
- Convert to CIELAB. Linear RGB to CIE XYZ to CIELAB, D65 white point, fully vectorized in NumPy rather than looped pixel by pixel.
- Bin, do not sample. Every pixel votes into a Lab space histogram on a 2 unit grid instead of being randomly subsampled. Random sampling can silently drop a small but important color, a logo occupying half a percent of the image, for instance. Binning keeps every pixel's vote at the right weight while collapsing a couple hundred thousand pixels down to a few hundred or thousand occupied bins.
- Cluster. Weighted k-means runs over those bins, not over raw pixels, with the requested number of colors as k. Each resulting cluster has a center (a color) and a mass (how much of the image it represents).
- Map back to sRGB, properly. Every cluster center gets converted from Lab back to sRGB for display. Colors that land inside the sRGB gamut go through the exact inverse of the matrix pipeline from step 4. Colors that fall outside the gamut do not get hard clipped, clipping each channel independently shifts hue, sometimes badly. Instead they go through proper gamut mapping, holding Oklch lightness and hue fixed and reducing chroma until the color lands back in gamut, the same approach the CSS Color 4 specification recommends.
- Rank and return. Colors are ranked by cluster mass, most represented first, and returned as JSON: hex code, RGB, Lab coordinates, and weight, alongside the original image size and how many histogram bins were used.
- Render. The frontend shows the palette as swatches in that ranked order, each labeled with its hex code and the percentage of the image it represents.
Every formula in that pipeline that could plausibly be wrong without looking wrong gets checked against an independently sourced reference before anything downstream trusts it. The delta E00 implementation is checked against all 34 published test pairs from Sharma, Wu and Dalal (2005). The full sRGB to Lab round trip is checked across 10,000 random colors with the error required to stay under one part in a million. That habit is the single biggest thing keeping this project honest, color math that looks plausible but is subtly wrong is very easy to write and very hard to catch by eye.
The gamut mapping step from above, the part that replaced a naive hard clip:
if np.any(out_of_gamut):
flat_xyz = xyz.reshape(-1, 3)
flat_srgb = srgb.reshape(-1, 3)
for i in np.flatnonzero(out_of_gamut.reshape(-1)):
mapped = Color("xyz-d65", list(flat_xyz[i])).convert("srgb").fit(method="oklch-chroma")
flat_srgb[i] = mapped[:-1]
srgb = flat_srgb.reshape(srgb.shape)In gamut colors, the large majority of them, never reach this branch at all, they take the plain matrix pipeline above. Only the colors that actually fall outside sRGB pay the cost of the more careful mapping.
What is described above is a deliberately simple baseline, Euclidean distance in Lab space, plain k-means, a k that gets typed in as a number. The actual research direction is the constrained version described in the idea section above: delta E00 as the real distance function instead of Euclidean, a role graph instead of a flat list of colors, APCA contrast as a hard constraint enforced during generation rather than checked afterward, and an exact constrained solve so a palette is either provably correct or provably infeasible, with a clear answer for which constraint is the problem when it is infeasible. None of that is built yet. What exists today is the foundation it gets built on top of.