Articles
ResearchChroma Rotation Averaging

Match the colors,
not the channels.

An upscaler brings back more detail and slightly wrong colors. Matching its histograms to the original pulls the colors back, but one channel at a time, and that sees the chroma plane from only two sides: a flat dark blue comes apart into purple blocks. Chroma Rotation Averaging matches along six directions, averages, and repeats.

The dark water above the mermaid after per-channel matching: blocks of purple in the blue.
A · per-channel
The same water after CRA: an even dark blue.
B · CRA
chroma plane
Fig. 01 / The dark water above the mermaid, matched to the original two ways. Drag across it. Inset: the picture's colors on the chroma plane, and the six directions CRA looks along.

6 directions3 rotations × 2 axes3 steps, 25 · 50 · 100%CIELAB · OKLab · tiledRust → WebAssembly, running on this pagesince February 2025

01

The drift

The upscale came back paler.

An upscaler pipeline turns a small image into a large one in several passes. Some resample, some invent detail, some repaint the whole picture at the new size. The detail is the point. The colors change on the way.

The original illustration: a white-haired mermaid in deep blue water among ruins, with bright cyan light on the sea floor.
Original
The same illustration after upscaling: sharper, with paler cyans and lighter dark blues.
Upscaled
Fig. 02 / The example from the first description of CRA, February 2025, both at 512 × 748. The mermaid is made with Polyverse Atelier. Drag to wipe.
Cyan, median chroma0.127 0.092OKLab, pixels with a cyan hue
Dark water, lightness0.394 0.416OKLab L, the water at the top

The cyans lost a quarter of their saturation, and the dark water came back lighter. The eyes changed too, but that is detail, not color, and no color correction should touch it.

The original is still at hand: it is what went into the upscaler. The standard way to pull colors back to it is histogram matching. For one channel, sort the values of both images and give each pixel of the upscale the value at the same rank in the original: darkest to darkest, median to median. The images need not line up or share a size. Afterwards the channel's histogram is the original's.

input, sortedreference, sorted
02

The failure

Why did the dark water turn purple?

The usual way to match a color image is in a Lab-type space, one channel at a time: lightness L, then a, then b. Each histogram comes out right. The colors do not have to.

The dark water in the original: an even deep blue.
OriginalWhat the colors should be.
The dark water in the upscale: lighter and flatter.
UpscaledLighter, a little flatter.
The dark water after per-channel matching: purple blocks.
Per-channel matchDarker again, in purple blocks.
The dark water after CRA: an even dark blue.
CRADarker again, and blue.

Fig. 03 / The same 240 × 180 pixels at each stage. The corrections are the compiled implementation's, with the lab's defaults: CIELAB, 256 bins.

Two shadows, not the colors

A color is a point on the chroma plane: the direction from gray is its hue, the distance its chroma. Per-channel matching sees that plane only as two shadows, one on the a axis and one on the b axis. Two images can cast the same shadows and hold different colors.

same asame bdifferentcolors

One curve for every pixel

Each channel gets a single curve for the whole image, from the input's values to the reference's. Every pixel with the same b gets the same new b, whatever its a. The slope of the curve is the ratio of the two histograms: where the input is crowded and the reference is not, the curve turns steep and pulls neighboring values apart. The dark water sits on such a stretch.

Near gray, a small push flips the hue

47% vs 11%

of the nearly gray pixels (CIELAB chroma 0.5 to 1) turn more than 90° round the hue circle under per-channel matching, and under CRA. Where a and b are both small, the same shifts carry a color to the other side of gray. Above chroma 2, neither method turns any.

Fig. 04 / The two curves of per-channel matchingDetails
The curves per-channel matching applies to a and b on the example: input across, output up, the diagonal for no change. Red where the curve stretches the input by more than 1.5 times; the steepest stretch is given for the middle 98% of the input's values. Beneath, the input's histogram (slate) and the reference's (amber). The band is where the dark water's pixels sit (5th to 95th percentile). Computed at half size with a JavaScript port of the cra-web code, checked against the compiled implementation that the lab in section 04 runs.
Channel b
Channel a
inputreferencestretch over 1.5×the dark water

“Technique to reduce bad color shifts (e.g. dark blues turning gray/purple-ish) and banding when histogram matching to a lower res reference.”The first announcement, February 2025[2]

03

The method

Look from six sides, not two.

If two directions are not enough, look along more. CRA rotates the chroma plane of both images, matches each rotated axis exactly as before, rotates back, and does this at 0°, 30° and 60°: six directions, one every 30°. Each rotation proposes a new place for every pixel, right along its own two directions and blind in between. The average keeps what they agree on.

1 / Rotate

Turn the a, b plane of both images by θ.

2 / Match each axis

Histogram-match the rotated a to the reference's rotated a; the same for b.

3 / Rotate back

Turn the matched plane by −θ. Do it for 0°, 30° and 60°, all from the same start.

4 / Average, blend

Average the three and blend into the image: 25%, then 50%, then 100%.

Then / One final pass

Match L, a and b once, one channel at a time. Lightness is only matched here.

Why no full step

A full match along any pair of axes commits every color to one side of each axis at once, and every step after it is a monotone curve that keeps that order: a color sent the wrong way stays there. Matching a and b in full is per-channel matching. Choosing the axes at random, one full step after another, as iterative distribution transfer does, does not remove the commitment. It only moves the failure somewhere unpredictable.

CRA does not take that step while the colors are still far off. Each step averages the proposals of six directions and moves part of the way, a quarter, then half of the rest, so no single pair of axes decides where a color goes. The only full match along one pair of axes is the last pass, when there is little left to decide.

600 scenes, colors at random, the reference turned 15° to 45°

Per-channela and b, in full0flippedmisses by 12–13°, tears 8–13% of the pixels off their color
CRAaveraged, partial steps0flippedmisses by 1–4°
Random axesone full step after another19flipped12 after 30 steps instead of 10; misses by 4–6°

Synthetic scenes on the chroma plane: two opposing colors, or two to four anywhere, and the same scene turned as the reference, so each color has a known target. A color flips when it lands more than 90° from it. The random-axes method is iterative distribution transfer after Pitié et al., here on the chroma plane. Past 45° every method fails often, CRA least.

Fig. 05 / Step through itDetails
The chroma plane of the example at each step, as a vectorscope: every pixel drawn in its own color, the directions the step matched along as dashed lines, the rim marked with the hues of the sRGB primaries and secondaries. The small scope is the difference from the reference: teal where the image has too many pixels, amber where it has too few. The rose is the distance from the reference along each direction of the plane: the Wasserstein-1 distance between the two images' values projected onto that direction, how far on average the pixels would have to move along it. Per-channel matching is drawn for comparison. Computed at a quarter size with the JavaScript port of the cra-web code (the compiled implementation does not expose the steps in between).
Step
 
Vectorscope
Distance by direction
Difference

Per-channel matching brings the distance to nothing on its two axes and leaves lobes between them. The first two CRA steps shrink the rose in every direction; the third, the full step, shrinks most of it and evens it out; the final pass pins the axes. Between them, the distance ends at about half of per-channel matching's, and the final pass moves a and b by a fifth of what the steps moved them.

04

The implementation

Run it on your own pictures.

This is the Rust code of cra-web, compiled to WebAssembly, running in a background worker: decoding with the images' color profiles, correcting in linear light, dithering to 8 bits for the screen. About 750 KB with gzip, loaded when you start it. Your pictures stay in your browser.

Fig. 06 / Grading suiteDetails
Pick two results for the monitor and drag across it to wipe between them. Zoom to see real pixels; when zoomed, drag to move and use the handle to wipe. Each correction runs once and is kept, so switching is instant; changing the space or the histograms runs them again.
Error against the original: both images averaged over 8 × 8 blocks in linear light, so that detail the upscaler added does not count, and the OKLab distance between the blocks averaged over the picture (× 100). Only for the upscale, where the reference is the same picture; for another picture the readings are the mean distance of the rose. The rose measures in the working space; for linear RGB, which is there for comparison, on the plane perpendicular to gray.
Histograms. 256 bins, dithered: the original method, each channel quantized to 256 levels with error diffusion before matching (cra-web's kernel-switching ditherer, a different seed for every pass). Colorspace-aware: a and b quantized together, picking the nearest pair of levels by perceptual distance; the cra-web default, and several times slower. Float, sorted: no bins, the matching sorts the values themselves.
Pictures
 
Zoom
Look at
The upscaled illustration, before correction.
Input
The original illustration, the reference.
Reference
Press Run to load the module and correct the upscale.
Distance by direction

Where to look

The dark water first. In CIELAB, per-channel matching breaks it into purple, with bins or without, and CRA keeps it blue. On the rose, per-channel matching is smallest on its two axes and bulges between them.

OKLab

cra-web's default space makes per-channel matching itself much better behaved on this picture: no purple. CRA still pulls the directions between the axes in.

The numbers

Error against the original, from 3.02 for the upscale: per-channel 2.42, CRA 2.31, tiled CRA 2.07 (CIELAB, 256 bins). With 256 bins nothing reaches zero on the axes; a bin is about one CIELAB unit wide.

05

Settings

One method, three choices.

Around the same rotate, match and average, three things can change: the plane it works on, how much of the picture one curve covers, and how finely the histograms are taken. The suite above has all three.

Which plane

CIELAB, as in the first scripts, or OKLab, which the Rust port added and cra-web has used by default since January 2026. CIELAB is known to bend blues toward purple as their chroma changes at a constant hue angle; OKLab was designed to keep hue straight, and on this picture per-channel matching alone avoids the purple in it. The ranges scale with the space: ±127 in CIELAB, ±0.5 in OKLab. Linear RGB is there to compare: with no chroma plane, CRA turns the whole cube about its gray axis, at 0°, 40° and 80°.

In the suite: Working space

How much of the picture

One curve per direction for the whole picture, or one per region. A cast that changes across the frame needs the second. The tiled form, there from the first scripts, cuts the image into a 9 × 9 grid and corrects 64 overlapping blocks of 2 × 2 tiles, each against the same region of the reference, then blends them with Hamming windows and matches once globally. Lightness comes from the input and is matched globally, or per block with the tiled-luminosity option. It needs a reference of the same scene, which an upscale has.

In the suite: Tiled CRA

How finely

In 256 bins, or with no bins at all. Binning rounds every value, so it has to be dithered, or the rounding bands. The first scripts used a simple Floyd–Steinberg pass and scikit-image. The Rust port has its own matching and dithers properly: with the kernel-switching ditherer[3] and a new seed for every pass, so the noise of the six matchings averages out, or with a and b quantized together by perceptual distance; and once more on the output, back to 8 bits. Without bins it sorts the values themselves and breaks ties with a seeded hash of the position. The bins span a fixed square, so at 30° and 60° they are 1.37 times wider.

In the suite: Histograms

06

Another use

Any picture can be the reference.

With a different picture as the reference, matching becomes color transfer: the input takes on the reference's palette. Here a Midjourney V6 image of a woman in a city is matched to a Midjourney V3 image of a woman in a park.[1]

Input: a woman at night in a city street with orange lanterns and blurred lights.
Input
Reference: a painterly woman in a sunny green park.
Reference
Per-channel result: cyan and orange tones, a greenish face with banding.
Per-channelcyan and orange, a greenish face, bands
CRA result: greener overall, with smooth shading in the face.
CRAgreener overall, smooth shading

Fig. 07 / As published with the Python scripts in February 2025. Run this pair in the suite above ↑

Use it

Take it into your own pipeline.

cra-web is the whole toolkit: the Rust implementation this page runs, a command-line tool for batch work, and the WebAssembly build, with rescaling and dithering beside it. BSD 3-Clause.

cra-web on GitHub
cra 1.15
$ cra -i upscale.png -r original.png -o out.png
# CRA in OKLab, the default with a reference

$ cra -i upscale.png -r original.png --histogram cra-lab -o out.png
# in CIELAB, as on this page

$ cra -i upscale.png -r original.png --histogram tiled-oklab -o out.png
# tiled, for a cast that changes across the frame
08

References

CRA

  1. Jan Boon (Kaetemi), chroma-rotation-averaging, GitHub, 2025-02-21. The Python scripts (per-channel, CRA and tiled, in CIELAB), written mostly by DeepSeek R1 and Claude Sonnet and edited by hand; the README with the first description of CRA, by Claude Opus; and the example images. BSD 3-Clause.
  2. Jan Boon (Kaetemi), posts on X: announcing the technique, 2025-02-23; the first cra-web demo, 2026-01-10.
  3. Jan Boon (Kaetemi), cra-web, GitHub, 2026. Started on 2026-01-10 as a browser demo of the scripts, built with Claude Code, and ported to Rust and WebAssembly the same day; OKLab, its own histogram matching and the proper dithering followed within four days. The kernel-switching ditherer: “Stochastic Kernel-Switching Error Diffusion,” blog.kaetemi.be, 2026. BSD 3-Clause.

Color transfer

  1. E. Reinhard, M. Ashikhmin, B. Gooch, P. Shirley, “Color Transfer between Images,” IEEE Computer Graphics and Applications 21(5), 2001, 34–41. doi:10.1109/38.946629.
  2. L. Neumann, A. Neumann, “Color Style Transfer Techniques using Hue, Lightness and Saturation Histogram Matching,” Computational Aesthetics in Graphics, Visualization and Imaging 2005, Eurographics, 111–122. doi:10.2312/COMPAESTH/COMPAESTH05/111-122.

Distribution transfer and sliced transport

  1. F. Pitié, A. C. Kokaram, R. Dahyot, “N-Dimensional Probability Density Function Transfer and its Application to Colour Transfer,” ICCV 2005, vol. 2, 1434–1439. doi:10.1109/ICCV.2005.166. Algorithm 1; section 4, the choice of rotations.
  2. F. Pitié, A. C. Kokaram, R. Dahyot, “Automated Colour Grading Using Colour Distribution Transfer,” Computer Vision and Image Understanding 107(1–2), 2007, 123–137. doi:10.1016/j.cviu.2006.11.011.
  3. F. Pitié, A. Kokaram, US patent 7,796,812, “Method for matching color in images,” filed 2006-10-17, granted 2010-09-14; expired.
  4. J. Rabin, G. Peyré, J. Delon, M. Bernot, “Wasserstein Barycenter and Its Application to Texture Mixing,” SSVM 2011, LNCS 6667, Springer, 2012, 435–446. doi:10.1007/978-3-642-24785-9_37.
  5. N. Bonneel, J. Rabin, G. Peyré, H. Pfister, “Sliced and Radon Wasserstein Barycenters of Measures,” Journal of Mathematical Imaging and Vision 51(1), 2015, 22–45. doi:10.1007/s10851-014-0506-3. Section 4.3, the Newton step (equation 40); section 4.4, Pitié's method as its stochastic form.

The mermaid illustration is made with Polyverse Atelier; the retargeting pair are Midjourney V6 and V3 images. The close-up crops are the compiled implementation's output on the example.

This article was written using AI assistance. The research, implementations, and measurements described are the author's own work.