Generating a Color Spectrum for an Image — Amanda Hinton

12 min read Original article ↗

colorful dragonfruit on pile of oranges

How do you show someone all the colors in a photograph?

This is the story of building the Spectrimage analyzer for Chromaculture, a tool that extracts and displays the color composition of an uploaded image. This exploration was the result of “Impossible Stuff Day” at Recurse Center, where engineers tackle something new or tough in a five-hour solo hackathon.

Iteration 1: Median Cut Bar

I started with a classic algorithm from image compression: median cut quantization. Put all pixels in the image in a bucket. Find which color channel (red, green, or blue) has the widest range of values. Sort by that channel and split the bucket in half. Repeat until you have 32 buckets. Average the colors in each bucket. Display them as a bar.

The result was 32 equally-sized color swatches arranged in a line. Tidy. But wrong. Every swatch was the same size regardless of how common that color was in the image. The 32-color cap threw away nuance from photographs. And the sort order was jumbled.

Median cut is designed to find representative colors for image compression, not to visualize color distribution. It equalizes bucket sizes by design, splitting the largest bucket each time, which is exactly what you want for reducing a 16-million-color image to 256 colors, and exactly what you don't want when the whole point is showing that orange takes up 60% of the photograph.

Iteration 2: Hue Histogram

If median cut doesn't preserve frequency, what does? A histogram. I convereed to HSL, and used hue to determine which bin each pixel falls into (72 bins spanning 5 degrees of the 360-degree hue wheel).

This fixed two of the three problems immediately. The bins were sorted by hue, so the spectrum now followed ROYGBIV order. And each bin's width was proportional to its pixel count, so we can see frequency.

But five-degree bins are coarse. And each bin averaged all the pixels within it into a single color, which meant a dark shadowed orange and a bright sunlit orange in the same 5-degree range became one muddy in-between.

Iteration 3: The Pixel-Level Sort

I had already paid the computational cost of downsampling the image, and reduced it to 300 pixels on its longest side. So why bin pixels at all? I couldn't put 60,000 divs in a flex container, but adjacent sorted pixels could be chunked, averaged, and rendered with flex: percentage. With 400+ segments instead of 72 bins, the spectrum should be rich and smooth.

It was not smooth. The orange section looked like a barcode with dark-light-dark-light striping, a rapid oscillation between burnt umber and bright tangerine. The problem was subtle: sorting by hue alone means that a dark pixel at hue 25 sits right next to a bright pixel at hue 25.01. When you chunk these together, one chunk might randomly get mostly dark pixels, and the next chunk mostly bright ones. Adjacent chunks at the same hue should average to similar values, but, the variance was visible.

Iteration 4: Band Sorting with Lightness

Maybe the fix was to control the order within each hue region. I made ROYGBIV bands and sorted pixels dark to light. This way, the red section would smoothly transition from deep burgundy through pure red to pale pink, then the orange section would do the same.

There was some improvement in the orange section, but it felt like a step back. I still had stripes and the ordering was worse.

Iteration 5: Continuous Hue with Degree-Level Sorting

Same idea, finer granularity. Each integer degree of hue became its own group, sorting by lightness within each degree, so all pixels at hue 25 would sort by lightness together, then all pixels at hue 26, and so on.

The transitions between adjacent degrees were nearly invisible, but the striping was really prominent, though at a finer scale. Within each degree, pixels went dark-to-light, then reset to dark at the next degree. The fundamental problem persisted: any lightness-based sub-sort creates periodic discontinuities.

Iteration 6: Canvas Rendering with Smoothing

I stripped out the lightness sub-sort entirely, going back to pure hue ordering, and changed the rendering approach. Instead of DOM elements, I drew directly onto an HTML Canvas, with one column per screen pixel, each column averaging the pixels that mapped to it.

To smooth, each column's final color was the average of itself and its three nearest neighbors on each side. Plus, the canvas rendering eliminated the overhead of hundreds of DOM elements.

It was getting closer to the gradient, but the banding was still there. And I felt like this was a dead end. Any one-dimensional ordering of pixels that vary in two dimensions will produce these stripey artifacts. Averaging can’t hide or eliminate them.

But looking back at iteration 5, gave me an idea. The banding was an inherent part of the color, I needed to make each dark-to-light section a position along the spectrum. Instead of asking how to sort these pixels into a smooth line, how can I best display the different vectors of information I am juggling. I turned my head to touch my ear to my shoulder and I could see it.

Iteration 7: Breakthrough with Another Dimension

This version renders the spectrum on an HTML Canvas, with each hue in a column painted as a linear gradient with tint at top, through pure at center, to shade at bottom. The darkest 20% are averaged to produce the shade color (bottom of the column). The middle 20% produce the pure color. The lightest 20% produce the tint (top of the column). Using 20% slices reduces outlier noise (such as a single nearly-black pixel from a deep crack between two oranges). The column's height is proportional to its pixel count relative to the most common hue.

The result looks something like a sound waveform. For the dragonfruit and oranges image, the orange columns tower, with pale peach tips at the top fading through vivid orange to dark browns at the base. A thin line of green represents a variety of hues with little tint/shade variation. And a concentration of magenta bubbles at the violet end. There is a small achromatic column at the far right with the image's few colorless pixels (black, white, and grays).

This visualization communicates which hues are present (x-position), how much of each hue exists (column height), and the tonal range of each hue in the image (the vertical gradient).

Iteration 8: Black and White Images

Black and white photographs presented a special case. With no hue to plot, the original visualization collapsed every pixel into one block. The gradient that would have been the rightmost gray column on a color image. Accurate, but useless.

When the analyzer detects that more than 95% of an image's pixels are achromatic (saturation below 3%), it switches axes. The x-axis becomes lightness, running from black on the left to white on the right, divided into 60 bins. Each bin still becomes a column whose height reflects how many pixels share that lightness level. The waveform shape, displaying frequency as height, stays consistent. A foggy landscape and a high-contrast portrait produce visually distinct silhouettes that immediately communicate their tonal character.

Further Refinement

The next step for Spectrimage is to generate a palette that feels more human, rather than this palette that is closer to fact than feeling. That spurred me to display more details for the colors actually present in the image.

Iteration 9: Split Chromatics and Achromatics and Increase Lightness Granularity

Every pixel lands in exactly one place: the chromatic spectrum if its HSL saturation is at or above 3% or the achromatic spectrum if it's below. If a spectrum's total pixel count is below 1% of the image, it’s hidden. Gradient strips beneath each spectrum gives you a key for interpreting what's there and what's missing.

For each column, the three-stop gradient was replaced with ten frequency-weighted stacked bands with the lightest on top, the darkest on the bottom, and nothing in between averaged away. Each band carries 10% of the hue's pixels, so the column's vertical extent is still frequency-weighted overall, but each column shows the full lightness ramp that the image actually contains. A hue dominated by mid-tones reads as a stack of similar mids. A hue split between lights and darks reads as a visibly two-toned column.

Iteration 10: Use Asymmetry to Convey Tonality

The ten equal-count bands fixed the information problem but made the symmetric layout feel wrong. A pastel image and a dark image both produced the same silhouette, just with different colors in the bands. Anchoring the pure band at the horizontal axis and letting the column grow asymmetrically around it allowed the shape of the spectrum to convey the image's tonal character.

Now eleven bands are calculated per image: the pure hue is shown centered on the horizontal axis with five tints above and five shades below. Band 0 is L=0-9.09; band 5 covers L=45.5-54.5; band 10 is L=90.9-100. Each band's height is its fraction of the image's biggest hue count, so a hue's bands add up to a total column area that scales with the hue's prevalence. A band with zero pixels contributes zero height to the column.

A dark image produces columns that hang below the line, with thin or empty bands above. A pastel image produces columns that lift above the line. A mixed image shows columns that extend both ways, with the asymmetry revealing which side each hue lives on.

Iteration 11: Refactor with OKLCH Color Space

An image I was troubleshooting had white-ish paper that was stubbornly spiking in a stretch of very light, slightly chromatic gray. HSL's saturation formula calculated 9.6%, well above my 3% chromatic threshold; the same thing happens at the dark end, where a near-black warm pixel reads as 12% saturated. HSL saturation is relative to the brightness ceiling at each lightness, not to anything a human eye would actually call saturated.

Switching to a color space that decoupled saturation and lightness would fix that. In OKLCH, L is lightness that matches the eye. C is chroma, independent of lightness, so a near-white off-white reports near-zero. And H is hue that walks the wheel evenly.

For my original fruit image, the chromatic scale now feels more intuitive and the achromatic scale registers as its own graph.

This gave two knock-on improvements to quiet issues I had been tolerating. In HSL, hue isn't perceptually uniform around the wheel (yellows compress, blues stretch), so 180 hue bins didn't divide the visible spectrum evenly. And HSL lightness is just (max + min) / 2 of the RGB channels, which ignores that human eyes weight green far more strongly than blue or that a saturated yellow at lightness 50 looks much brighter than a deep blue at the same value.

Swapping in the new coordinates simplified the code. The chromatic threshold is one value of C: pixels below 0.02 go to the achromatic spectrum, with no zone tricks or hybrid rules. Hue binning uses H with a wrap at 15° so red anchors the leftmost bin. The 60 achromatic bins and the 11 within-column bands both use OKLCH L directly, so a band at the centerline really is a middle-bright pixel.

How To Read the Spectrum

The spectrum bar packs five dimensions of a photograph into one silhouette.

Horizontally, every column is a hue (H in OKLCH where equal horizontal steps feel like equal hue hops). A column's x-position is hue; the colored strip beneath the bar is a fixed reference so an empty stretch means the hue doesn't appear in the image.

Vertically, each column's total height is how often that hue appears. The taller the column, the more pixels of that hue.

Within a column, the stacked bands describe the lightness distribution of that hue. A band's y-position is its lightness (L in OKLCH where vertical steps feel like equal brightness changes). The horizontal axis sits at L=0.5, bands above get progressively lighter, bands below get progressively darker. A band's height is how frequent that specific lightness is for that hue. A thick band means many pixels of that color at that lightness; a thin band means few; a missing band means none.

A column's asymmetry around the horizontal axis is the tonal character of that hue. Columns that hang below the line belong to darker pixels; columns that reach above the line belong to lighter pixels.

The color of each band is the average of the pixels it represents.

How It Works

The whole process runs client-side in the browser. No server round-trip, no image upload to external services, no dependencies beyond the Canvas API and my OKLCH conversion utilities. A 4000×3000 photograph completes analysis in under a second.

The image is scaled down so its longest dimension is 300 pixels. Aspect ratio is preserved. This keeps the sample size constant across wildly different resolutions.

Each pixel is converted from RGB to OKLCH and routed into one of two spectra. Fully transparent pixels (alpha < 128) are dropped. Chromatic pixels (OKLCH chroma ≥ 0.02) are binned by hue into 180 bins of 2° each, with a wrap point at 15° so the deepest reds anchor at the left edge of the ROYGBIV strip. Achromatic pixels (chroma < 0.02) are binned by lightness into 60 bins.

For each hue bin, pixels are grouped into 11 bands by fixed OKLCH lightness range, each spanning about 0.091 of the 0-to-1 lightness scale. Each band holds the average RGB of the pixels that fell into its range and a count of how many pixels that was. Achromatic columns are built the same way but look like one filled block (banding mechanism still works, it just has nothing to distribute).

Each column sits at its hue's x-position (or lightness position, for the achromatic bar). Band 5 (the pure row) straddles the horizontal axis of the bar, half above and half below. Tints stack upward from the top of the pure row; shades stack downward from the bottom. Each band's height is its pixel count divided by the largest hue-bin count across both spectra, times half the bar's height — so one full-height half means that band represents as many pixels as the image's most-populous hue bin.