16

Given two rgb colors and a rectangle, I'm able to create a basic linear gradient. This blog post gives very good explanation on how to create it. But I want to add one more variable to this algorithm, angle. I want to create linear gradient where I can specified the angle of the color.

For example, I have a rectangle (400x100). From color is red (255, 0, 0) and to color is green (0, 255, 0) and angle is 0°, so I will have the following color gradient.

enter image description here

Given I have the same rectangle, from color and to color. But this time I change angle to 45°. So I should have the following color gradient.

enter image description here

akuzminykh
  • 4,522
  • 4
  • 15
  • 36
Sophy
  • 401
  • 1
  • 5
  • 10
  • Looks like every line is just moved by one pixel to the right. Depending on the angle, have to add some constant to the calulation of `curr_vector` from your example. – usr1234567 Mar 24 '14 at 10:47
  • 1
    there's a crucial part missing from the input parameters. if you ever used photoshop/gimp/... you would know that the gradient is between two points. giving just an angle makes it ambiguous... how far should it be stretched? – Karoly Horvath Mar 24 '14 at 10:54
  • @KarolyHorvath In photoshop you can create gradient between more than two colors and each color has its own location. In the example above the gradient should be stretched within the rectangle (400x100) – Sophy Mar 24 '14 at 11:00
  • @Sophy: I was talking about drawing a gradient on the canvas (actually using it), and not defining one. – Karoly Horvath Mar 24 '14 at 11:00
  • @KarolyHorvath It depends on the size of the canvas. If the canvas size is 200x300, the gradient should be painted on 200 width. – Sophy Mar 24 '14 at 11:06
  • Independent of the angle? All I'm saying: what you're doing isn't really flexible. – Karoly Horvath Mar 24 '14 at 11:07

5 Answers5

34

Your question actually consists of two parts:

  1. How to generate a smooth color gradient between two colors.
  2. How to render a gradient on an angle.

The intensity of the gradient must be constant in a perceptual color space or it will look unnaturally dark or light at points in the gradient. You can see this easily in a gradient based on simple interpolation of the sRGB values, particularly the red-green gradient is too dark in the middle. Using interpolation on linear values rather than gamma-corrected values makes the red-green gradient better, but at the expense of the back-white gradient. By separating the light intensities from the color you can get the best of both worlds.

Often when a perceptual color space is required, the Lab color space will be proposed. I think sometimes it goes too far, because it tries to accommodate the perception that blue is darker than an equivalent intensity of other colors such as yellow. This is true, but we are used to seeing this effect in our natural environment and in a gradient you end up with an overcompensation.

A power-law function of 0.43 was experimentally determined by researchers to be the best fit for relating gray light intensity to perceived brightness.

I have taken here the wonderful samples prepared by Ian Boyd and added my own proposed method at the end. I hope you'll agree that this new method is superior in all cases.

Algorithm MarkMix
   Input:
      color1: Color, (rgb)   The first color to mix
      color2: Color, (rgb)   The second color to mix
      mix:    Number, (0..1) The mix ratio. 0 ==> pure Color1, 1 ==> pure Color2
   Output:
      color:  Color, (rgb)   The mixed color
   
   //Convert each color component from 0..255 to 0..1
   r1, g1, b1 ← Normalize(color1)
   r2, g2, b2 ← Normalize(color1)

   //Apply inverse sRGB companding to convert each channel into linear light
   r1, g1, b1 ← sRGBInverseCompanding(r1, g1, b1)       
   r2, g2, b2 ← sRGBInverseCompanding(r2, g2, b2)

   //Linearly interpolate r, g, b values using mix (0..1)
   r ← LinearInterpolation(r1, r2, mix)
   g ← LinearInterpolation(g1, g2, mix)
   b ← LinearInterpolation(b1, b2, mix)

   //Compute a measure of brightness of the two colors using empirically determined gamma
   gamma ← 0.43
   brightness1 ← Pow(r1+g1+b1, gamma)
   brightness2 ← Pow(r2+g2+b2, gamma)

   //Interpolate a new brightness value, and convert back to linear light
   brightness ← LinearInterpolation(brightness1, brightness2, mix)
   intensity ← Pow(brightness, 1/gamma)

   //Apply adjustment factor to each rgb value based
   if ((r+g+b) != 0) then
      factor ← (intensity / (r+g+b))
      r ← r * factor
      g ← g * factor
      b ← b * factor
   end if

   //Apply sRGB companding to convert from linear to perceptual light
   r, g, b ← sRGBCompanding(r, g, b)

   //Convert color components from 0..1 to 0..255
   Result ← MakeColor(r, g, b)
End Algorithm MarkMix

Here's the code in Python:

def all_channels(func):
    def wrapper(channel, *args, **kwargs):
        try:
            return func(channel, *args, **kwargs)
        except TypeError:
            return tuple(func(c, *args, **kwargs) for c in channel)
    return wrapper

@all_channels
def to_sRGB_f(x):
    ''' Returns a sRGB value in the range [0,1]
        for linear input in [0,1].
    '''
    return 12.92*x if x <= 0.0031308 else (1.055 * (x ** (1/2.4))) - 0.055

@all_channels
def to_sRGB(x):
    ''' Returns a sRGB value in the range [0,255]
        for linear input in [0,1]
    '''
    return int(255.9999 * to_sRGB_f(x))

@all_channels
def from_sRGB(x):
    ''' Returns a linear value in the range [0,1]
        for sRGB input in [0,255].
    '''
    x /= 255.0
    if x <= 0.04045:
        y = x / 12.92
    else:
        y = ((x + 0.055) / 1.055) ** 2.4
    return y

def all_channels2(func):
    def wrapper(channel1, channel2, *args, **kwargs):
        try:
            return func(channel1, channel2, *args, **kwargs)
        except TypeError:
            return tuple(func(c1, c2, *args, **kwargs) for c1,c2 in zip(channel1, channel2))
    return wrapper

@all_channels2
def lerp(color1, color2, frac):
    return color1 * (1 - frac) + color2 * frac



def perceptual_steps(color1, color2, steps):
    gamma = .43
    color1_lin = from_sRGB(color1)
    bright1 = sum(color1_lin)**gamma
    color2_lin = from_sRGB(color2)
    bright2 = sum(color2_lin)**gamma
    for step in range(steps):
        intensity = lerp(bright1, bright2, step, steps) ** (1/gamma)
        color = lerp(color1_lin, color2_lin, step, steps)
        if sum(color) != 0:
            color = [c * intensity / sum(color) for c in color]
        color = to_sRGB(color)
        yield color

red-green gradient

green-blue gradient

blue-red gradient

black-white gradient

red-white gradient

red-black gradient

Now for part 2 of your question. You need an equation to define the line that represents the midpoint of the gradient, and a distance from the line that corresponds to the endpoint colors of the gradient. It would be natural to put the endpoints at the farthest corners of the rectangle, but judging by your example in the question that is not what you did. I picked a distance of 71 pixels to approximate the example.

The code to generate the gradient needs to change slightly from what's shown above, to be a little more flexible. Instead of breaking the gradient into a fixed number of steps, it is calculated on a continuum based on the parameter t which ranges between 0.0 and 1.0.

class Line:
    ''' Defines a line of the form ax + by + c = 0 '''
    def __init__(self, a, b, c=None):
        if c is None:
            x1,y1 = a
            x2,y2 = b
            a = y2 - y1
            b = x1 - x2
            c = x2*y1 - y2*x1
        self.a = a
        self.b = b
        self.c = c
        self.distance_multiplier = 1.0 / sqrt(a*a + b*b)

    def distance(self, x, y):
        ''' Using the equation from
            https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_an_equation
            modified so that the distance can be positive or negative depending
            on which side of the line it's on.
        '''
        return (self.a * x + self.b * y + self.c) * self.distance_multiplier

class PerceptualGradient:
    GAMMA = .43
    def __init__(self, color1, color2):
        self.color1_lin = from_sRGB(color1)
        self.bright1 = sum(self.color1_lin)**self.GAMMA
        self.color2_lin = from_sRGB(color2)
        self.bright2 = sum(self.color2_lin)**self.GAMMA

    def color(self, t):
        ''' Return the gradient color for a parameter in the range [0.0, 1.0].
        '''
        intensity = lerp(self.bright1, self.bright2, t) ** (1/self.GAMMA)
        col = lerp(self.color1_lin, self.color2_lin, t)
        total = sum(col)
        if total != 0:
            col = [c * intensity / total for c in col]
        col = to_sRGB(col)
        return col

def fill_gradient(im, gradient_color, line_distance=None, max_distance=None):
    w, h = im.size
    if line_distance is None:
        def line_distance(x, y):
            return x - ((w-1) / 2.0) # vertical line through the middle
    ul = line_distance(0, 0)
    ur = line_distance(w-1, 0)
    ll = line_distance(0, h-1)
    lr = line_distance(w-1, h-1)
    if max_distance is None:
        low = min([ul, ur, ll, lr])
        high = max([ul, ur, ll, lr])
        max_distance = min(abs(low), abs(high))
    pix = im.load()
    for y in range(h):
        for x in range(w):
            dist = line_distance(x, y)
            ratio = 0.5 + 0.5 * dist / max_distance
            ratio = max(0.0, min(1.0, ratio))
            if ul > ur: ratio = 1.0 - ratio
            pix[x, y] = gradient_color(ratio)

>>> w, h = 406, 101
>>> im = Image.new('RGB', [w, h])
>>> line = Line([w/2 - h/2, 0], [w/2 + h/2, h-1])
>>> grad = PerceptualGradient([252, 13, 27], [41, 253, 46])
>>> fill_gradient(im, grad.color, line.distance, 71)

And here's the result of the above:

Red-Green gradient on a 45 degree angle

pjpscriv
  • 866
  • 11
  • 20
Mark Ransom
  • 299,747
  • 42
  • 398
  • 622
  • I assume that `sum(color1_lin)` takes the sum of R+G+B (each in the range of 0..1), and returns a scalar. And i assume that in the simplest case of mixing two colors, `lerp((bright1, bright2), step, steps)` takes the interpolation between `bright1` and `bright2`. But what in the devil is `zip(color1_lin, color2_lin)` doing? What is [zip](https://docs.python.org/3.3/library/functions.html#zip). I want to compare results, and i'd start with a `c = ColorMix(c1, c2, ratio)` – Ian Boyd Mar 16 '18 at 14:45
  • @IanBoyd sorry, I used a lot of Pythonisms in that code. Yes, `sum` just gives you the sum of all the channels. I meant to include the code for `lerp` but I was in a hurry, sorry - I'll add it later. It's just a linear interpolation. `zip` is a Python built-in that makes pairs out of the components of two arrays, so you get (R1, R2), (G1, G2), (B1, B2). – Mark Ransom Mar 16 '18 at 15:18
  • repo with instructions https://github.com/rofrol/color-gradient-algorithm – rofrol Nov 18 '19 at 20:15
  • This algorithm is neat, but I think it can be improved even further. Rather than calculating the "brightness" as `Pow(r+g+b, gamma)`, you should weight each channel by its Relative Luminance, for even more perceptual accuracy. i.e. `Pow((r*0.2126+g*0.7152+b*0.0722)*3, gamma)` – Retr0id Jul 07 '21 at 15:37
  • @Retr0id I don't believe perceptual accuracy is the best way to go here, or Lab colors would give the best results. – Mark Ransom Jul 07 '21 at 16:57
  • 1
    Playing with this was really interesting, thanks @MarkRansom. I found it [crushes the blacks](http://n4te.com/x/4126-32qN.png) a little bit. This is what I found works best: I store colors in HSLuv, to lerp I convert to RGB, mix in linear, then back to HSL for the result. The trick is one more step: lerp the L from the start/end colors and set it on the result to restore the lightness. With that here is [black to white](http://n4te.com/x/4127-iGWD.png). The results for colors are slightly better too: [Mark](http://n4te.com/x/4128-lDmO.png) and [Nate](http://n4te.com/x/4129-2G2A.png). Cheers! – NateS Feb 25 '22 at 02:25
  • I've been using your algorithm for years now, but i found a case today where it seem to fall over dead: https://i.stack.imgur.com/wjMZ3.png Back to the color gradient drawing board! – Ian Boyd Nov 02 '22 at 19:41
  • @IanBoyd thank you very much for that example! I will need to study it a bit to find out how it fails. I'd say xyY produces the most satisfying gradient for those endpoints. – Mark Ransom Nov 02 '22 at 19:54
  • @IanBoyd I get different results than you - mine doesn't slam to pure white right away, the brightness transitions smoothly from one end to the other. It's closer to gray than I'd expect but still acceptable. – Mark Ransom Nov 02 '22 at 20:21
20

I wanted to point out the common mistake that happens in color mixing when people try average the r, g, and b components:

R = (R1 + R2) / 2;
G = (G1 + G2) / 2;
B = (B1 + B2) / 2;

You can watch the excellent 4 Minute Physics video on the subject:

Computer Color is Broken

The short version is that trying to niavely mixing two colors by averaging the components is wrong:

R = R1*(1-mix) + R2*mix;
G = G1*(1-mix) + G2*mix;
B = B1*(1-mix) + B2*mix;

The problem is that RGB colors on computers are in the sRGB color space. And those numerical values have a gamma of approx 2.4 applied. In order to mix the colors correctly you must first undo this gamma adjustment:

  • undo the gamma adjustment
  • apply your r,g,b mixing algorithm above
  • reapply the gamma

Without applying the inverse gamma, the mixed colors are darker than they're supposed to be. This can be seen in a side-by-side color gradient experiment.

  • Top (wrong): without accounting for sRGB gamma
  • Bottom (right): with accounting for sRGB gamma

enter image description here

The algorithm

Rather than the naive:

//This is the wrong algorithm. Don't do this
Color ColorMixWrong(Color c1, Color c2, Single mix)
{
   //Mix [0..1]
   //  0   --> all c1
   //  0.5 --> equal mix of c1 and c2
   //  1   --> all c2
   Color result;

   result.r = c1.r*(1-mix) + c2.r*(mix);
   result.g = c1.g*(1-mix) + c2.g*(mix);
   result.b = c1.b*(1-mix) + c2.b*(mix);

   return result;
}

The correct form is:

//This is the wrong algorithm. Don't do this
Color ColorMix(Color c1, Color c2, Single mix)
{
   //Mix [0..1]
   //  0   --> all c1
   //  0.5 --> equal mix of c1 and c2
   //  1   --> all c2

   //Invert sRGB gamma compression
   c1 = InverseSrgbCompanding(c1);
   c2 = InverseSrgbCompanding(c2);

   result.r = c1.r*(1-mix) + c2.r*(mix);
   result.g = c1.g*(1-mix) + c2.g*(mix);
   result.b = c1.b*(1-mix) + c2.b*(mix);

   //Reapply sRGB gamma compression
   result = SrgbCompanding(result);

   return result;
}

The gamma adjustment of sRGB isn't quite just 2.4. They actually have a linear section near black - so it's a piecewise function.

Color InverseSrgbCompanding(Color c)
{
    //Convert color from 0..255 to 0..1
    Single r = c.r / 255;
    Single g = c.g / 255;
    Single b = c.b / 255;

    //Inverse Red, Green, and Blue
    if (r > 0.04045) r = Power((r+0.055)/1.055, 2.4) else r = r / 12.92;
    if (g > 0.04045) g = Power((g+0.055)/1.055, 2.4) else g = g / 12.92;
    if (b > 0.04045) b = Power((b+0.055)/1.055, 2.4) else b = b / 12.92;

    //return new color. Convert 0..1 back into 0..255
    Color result;
    result.r = r*255;
    result.g = g*255;
    result.b = b*255;

    return result;
}

And you re-apply the companding as:

Color SrgbCompanding(Color c)
{
    //Convert color from 0..255 to 0..1
    Single r = c.r / 255;
    Single g = c.g / 255;
    Single b = c.b / 255;

    //Apply companding to Red, Green, and Blue
    if (r > 0.0031308) r = 1.055*Power(r, 1/2.4)-0.055 else r = r * 12.92;
    if (g > 0.0031308) g = 1.055*Power(g, 1/2.4)-0.055 else g = g * 12.92;
    if (b > 0.0031308) b = 1.055*Power(b, 1/2.4)-0.055 else b = b * 12.92;

    //return new color. Convert 0..1 back into 0..255
    Color result;
    result.r = r*255;
    result.g = g*255;
    result.b = b*255;

    return result;
}

Update: Mark's right

I tested @MarkRansom comment that the color blending in linear RGB space is good when colors are equal RGB total value; but the linear blending scale does not seem linear - especially for the black-white case.

So i tried mixing in Lab color space, as my intuition suggested (as well as this photography stackexchange answer):

enter image description here
enter image description here
enter image description here
enter image description here
enter image description here
enter image description here

Mark's algorithm sometimes falls over

enter image description here

Ian Boyd
  • 246,734
  • 253
  • 869
  • 1,219
  • 1
    This answer is too simplistic. It works great when the R+G+B values of both endpoints are identical, as in the example. It's not so great when the endpoints are different, and it's worst when one endpoint is black and the other white - you need to account for the difference between perceptual intervals and linear intervals. I'm struggling with that distinction myself right now, don't have a good answer yet. – Mark Ransom Oct 12 '16 at 22:48
  • I'm glad I came back to check, I see you've improved your answer. Lab space is indeed a better way, but it's not perfect. You've hit on probably the worst case, the green-blue gradient. It contains a lot of red, even though there's no red in either of the endpoints. This is why I'm still struggling to create the perfect gradient. Oh and by the way, you haven't answered the actual question - which is how to create a gradient *on an angle*. – Mark Ransom Oct 16 '16 at 05:30
  • @MarkRansom Yes, i spent the weekend going down the rabbit hole of xyY, XYZ, additive light, spectral power densities. While inverse gamma of sRGB is *mathematically* correct, it isn't aesthetically pleasing. And the L\*a\*b\* gradient is pleasing sometimes, it is distinctly unpleasing others. It starts to get into the philosophical question of what it means to *"mix"* two colors. Are we adding different intensities of light? Or are we passing the original red light through a green filter of differing strengths. You're right, there is no good answer. – Ian Boyd Oct 17 '16 at 20:52
  • If anyone is interested, I took this code and created a class that blends from one colour to another based on a target value - https://gist.github.com/Steztric/b6582d046dab754850417ca4ee2cba38 – Steztric Jul 16 '17 at 13:54
  • @IanBoyd Thank-you for noting the correct method of averaging colors. For more information on multiple methods of averaging colors, refer to http://tdlc.ucsd.edu/SV2013/Kanan_Cottrell_PLOS_Color_2012.pdf – FluxIX Aug 03 '17 at 19:52
  • 1
    I finally got around to posting my own answer, check it out. – Mark Ransom Mar 16 '18 at 12:55
  • The _approximate_ sRGB gamma is 2.2, not 2.4, see the comparison of the sRGB gamma, x^2.2 and x^2.4 curves [here](https://sendeyo.com/up/d/1301cb38cb). – Ruslan May 14 '20 at 18:16
  • @Ruslan As long as we make sure to tell people not to use 2.2 *or* 2.4 - since sRGB is neither. – Ian Boyd May 15 '20 at 14:47
  • Not sure why this answer was edited to include the unverified "failure" of Mark's algorithm posted two days ago in a different comment. I also implemented Mark's algorithm, and I agree with Mark that the results are different from what is shown in that image. I suspect this failure is a result of an implementation error. – Quantum64 Nov 04 '22 at 16:57
  • @Quantum64 It was edited because it's my answer and i edited it. – Ian Boyd Nov 05 '22 at 04:46
9

That's quite simple. Besides angle, you would actually need one more parameter, i.e. how tight/wide the gradient should be. Let's instead just work with two points:

                                         __D
                                     __--
                                 __--
                             __--
                         __--
                        M

Where M is the middle point of the gradient (between red and green) and D shows the direction and distance. Therefore, the gradient becomes:

                  M'
                   |                     __D
                    |                __--
                     |           __--
                      |      __--
                       | __--
                        M
                   __--  |
               __--       |
           __--            |
       __--                 |
   D'--                      |
                             M"

Which means, along the vector D'D, you change from red to green, linearly as you already know. Along the vector M'M", you keep the color constant.


That was the theory. Now implementation depends on how you actually draw the pixels. Let's assume nothing and say you want to decide the color pixel by pixel (so you can draw in any pixel order.)

That's simple! Let's take a point:

                  M'
                   | SA                  __D
                __--|                __--
               P--   |__ A       __--
               |  -- /| \    __--
                |   -- | |_--
                 |    --M
                  |__--  |
               __--CA     |
           __--            |
       __--                 |
   D'--                      |
                             M"

Point P, has angle A with the coordinate system defined by M and D. We know that along the vector M'M", the color doesn't change, so sin(A) doesn't have any significance. Instead, cos(A) shows relatively how far towards D or D' the pixels color should go to. The point CA shows |PM|cos(A) which means the mapping of P over the line defined by M and D, or in details the length of the line PM multiplied by cos(A).

So the algorithm becomes as follows

  • For every pixel
    • Calculate CA
    • If farther than D, definitely green. If before D', definitely red.
    • Else find the color from red to green based on the ratio of |D'CA|/|D'D|

Based on your comments, if you want to determine the wideness from the canvas size, you can easily calculate D based on your input angle and canvas size, although I personally advise using a separate parameter.

Shahbaz
  • 46,337
  • 19
  • 116
  • 182
  • @AssadEbrahim, I keep getting [that](https://stackoverflow.com/questions/7761974/non-recursive-merge-sort-with-two-nested-loops-how/7762159#comment9449589_7762159), are my answers worthless? ;) – Shahbaz Mar 24 '14 at 11:31
  • Doubt it, as you can see from the link you posted -- +36 is not too shabby for a response... :) – Assad Ebrahim Mar 24 '14 at 11:34
2

The way I solved this is first by being able to calculate L (lightness) for an RGB color: calculate only the Y (luminance) of CIE XYZ and use that to get L.

static private float rgbToL (float r, float g, float b) {
    float Y = 0.21263900587151f * r + 0.71516867876775f * g + 0.072192315360733f * b;
    return Y <= 0.0088564516f ? Y * 9.032962962f : 1.16f * (float)Math.pow(Y, 1 / 3f) - 0.16f;
}

That gives L as 0-1 for any RGB. Then to lerp RGB: first interpolate linear RGB, then fix lightness by lerping the start/end L and scale the RGB by targetL / resultL. I posted an Rgb class that does this.

The same library also has an Hsl class which stores a color as HSLuv. It does interpolation by converting to linear RGB, interpolating, converting back to HSLuv and then fixing the brightness by interpolating L from the start/end HSLuv colors.

NateS
  • 5,751
  • 4
  • 49
  • 59
-1

The comment of @user2799037 is totally correct: each line is moved by some pixels to the right compared to the previous one.

The actual constant can be computed as the tangent of the angle you specified.

elias
  • 849
  • 13
  • 28