Color gradient algorithm

后端 未结 4 1311
别那么骄傲
别那么骄傲 2020-12-23 15:17

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

相关标签:
4条回答
  • 2020-12-23 15:59

    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

    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):






    0 讨论(0)
  • 2020-12-23 16:03

    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
    

    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:

    0 讨论(0)
  • 2020-12-23 16:08

    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.

    0 讨论(0)
  • 2020-12-23 16:21

    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.

    0 讨论(0)
提交回复
热议问题