Search code examples
c#colorsw3cwcagcontrast

I'm consistently getting the incorrect value for a contrast ratio between two colours in a contrast finder in C#


I am attempting to write a class library that can be used to take an input colour in any format and returns one of two colours that subscribes to the W3 standards for good readability. Due to the font weight for the specific case, I only need to meet the 4.5:1 ratio for this version.

In order to calculate the contrast I am using the formula (l1 + 0.0005) / (l2 + 0.0005) to work out the contrast ratio. To do this I need to calculate the luminance of the RGB colour, the method I am using is based originally on this method because it is an expansion to an HSL colour space I have written.

This is the method used to calculate which text colour to return. It's designed to have default colours of #121212 and #EAEAEA as the text colours for light or dark backgrounds respectively. However, if neither of these is good enough contrast, then it tries again with #000000 and #FFFFFF. If neither of these work, it defaults out black.

public static (int R, int G, int B) FindTextColor(int R, int G, int B, int darkR = 0x12, int darkG = 0x12, int darkB = 0x12, int lightR = 0xEA, int lightG = 0xEA, int lightB = 0xEA)
{
    double bgLuminance = GetLuminanceOfRGB(R, G, B);

    double lightTextLuminance = GetLuminanceOfRGB(lightR, lightG, lightB);
    double lightContrastRatio = (Math.Max(lightTextLuminance, bgLuminance) + 0.0005) / (Math.Min(lightTextLuminance, bgLuminance) + 0.0005);

    double darkTextLuminance = GetLuminanceOfRGB(darkR, darkG, darkB);
    double darkContrastRatio = (Math.Max(darkTextLuminance, bgLuminance) + 0.0005) / (Math.Min(darkTextLuminance, bgLuminance) + 0.0005);

    if (lightContrastRatio >= 4.5)
    {
        return (lightR, lightG, lightB);
    }
    else if (darkContrastRatio >= 4.5)
    {
        return (darkR, darkG, darkB);
    }
    else
    {
        if (darkR != 0x00 && darkG != 0x00 && darkB != 0x00 && lightR != 0xFF && lightG != 0xFF && lightB != 0xFF)
        {
            return FindTextColor(R, G, B, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF);
        }
        else
        {
            return (0, 0, 0);
        }
    }
}

The following is the method GetLuminanceOfRGB(), which uses the first stage of the conversion in the previously mentioned link.

public static double GetLuminanceOfRGB(int R, int G, int B)
{
    //1, Convert the 1-255 RGB values into 0-1 values
    double red = R / (double)255;
    double green = G / (double)255;
    double blue = B / (double)255;

    //2, Find the minimum and maximum values out of R, G and B
    double minimum = Math.Min(Math.Min(red, green), Math.Min(green, blue));
    double maximum = Math.Max(Math.Max(red, green), Math.Max(green, blue));

    //3, Calculate the luminance from the max and minimum.
    return (double)Math.Ceiling((maximum + minimum) / 2.0 * 100) / 100;
}

This produces values for the luminance in the form of a double between 0 and 1 which represents a percentage where 0 is black and 1 is white.

Most of the time, this method works fine for colours like #FFFF00 or #FF0000. The issue arises when the blue channel has precidence.

The colour that I am having particular trouble with is #3041FF (A sort of blue) that has a luminance of 59%. While this is above 50%, the blue shades are much darker in appearance than a yellow would be, despite it being theoretically bright.

Currently, the method tells me that the contrast ratio between #3041FF and #EAEAEA is 1.53 approximately, whereas this contrast finder tells me the contrast ratio is 5.25.

I can supply the full text for the RGB-HSL conversion method if requested.

So far I have attempted several things, including changing my luminance to this calculation: L = 0.2126 * R + 0.7152 * G + 0.0722 * B, which didn't solve the issue, it just made the contrast ratios for light around 11.4 and dark around 11.8, which does not resolve the issue.

I also attempted using relative luminance using this method:

public static double GetRelativeLuminance(double L) {
    if (L < 0 || L > 1) {
        throw new ArgumentOutOfRangeException("Luminance may not be greater than 1 or less than 0.");
    }
    double lr = 0;
    if (L <= 0.04045)
    {
        lr = (L + 0.05) / 0.255;
    }
    else
    {
        lr = Math.Pow(((L + 0.05) / 1.055), 2.4);
    }
    return lr;
}

Which also did not resolve the issue, however looking back on it I may have gotten the scale wrong with the if statement.

I have also tried just creating HSL colours from the original hex input and using the luminance of those, just in case I had written the new method wrong in some fashion.

All of this has led to the same result of the code giving me the #121212 font colour, and I'm not sure why any more.


Solution

  • I managed to solve the issue eventually. Posting my results below so it may help others.

    The first method, FindTextColor() has remained unchanged, however I will repost the information below. This method calculates whether to display the light or dark text colour on the front, if neither supplied or default colour works, it calls itself with black and white. If this still fails for some reason, it will default to returning black.
    It should never need to use black or white, but it's a good safety net.

    public static (int R, int G, int B) FindTextColor(int R, int G, int B, int darkR = 0x12, int darkG = 0x12, int darkB = 0x12, int lightR = 0xEA, int lightG = 0xEA, int lightB = 0xEA)
    {
        double bgLuminance = GetLuminanceOfRGB(R, G, B);
    
        double lightTextLuminance = GetLuminanceOfRGB(lightR, lightG, lightB);
        double lightContrastRatio = (Math.Max(lightTextLuminance, bgLuminance) + 0.05) / (Math.Min(lightTextLuminance, bgLuminance) + 0.05);
    
        double darkTextLuminance = GetLuminanceOfRGB(darkR, darkG, darkB);
        double darkContrastRatio = (Math.Max(darkTextLuminance, bgLuminance) + 0.05) / (Math.Min(darkTextLuminance, bgLuminance) + 0.05);
    
        if (lightContrastRatio >= 4.5)
        {
            return (lightR, lightG, lightB);
        }
        else if (darkContrastRatio >= 4.5)
        {
            return (darkR, darkG, darkB);
        }
        else
        {
            if (darkR != 0x00 && darkG != 0x00 && darkB != 0x00 && lightR != 0xFF && lightG != 0xFF && lightB != 0xFF)
            {
                return FindTextColor(R, G, B, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF);
            }
            else
            {
                return (0, 0, 0);
            }
        }
    }
    

    It uses Math.Max() and Math.Min() in order to ensure that we always put the light colour over the dark.

    The biggest change came to my implementation of GetLuminanceOfRGB(), the main reason for my issues was that I was using gamma encoded sRGB, but I needed to use Linear RGB.

    public static double GetLuminanceOfRGB(int R, int G, int B)
    {
        double red = LineariseChannel(R);
        double green = LineariseChannel(G);
        double blue = LineariseChannel(B);
    
        return 0.2125 * red + 0.7154 * green + 0.0721 * blue;
    }
    

    In this method, we need to use the linear channels, I was originally using just the sRGB values within the formula at the bottom.

    private static double LineariseChannel(int channel)
    {
        double dbl = channel / 255.0;
        if (dbl <= 0.03928)
        {
            return dbl / 12.92;
        }
        else
        {
            return Math.Pow(((dbl + 0.055) / 1.055), 2.4);
        }
    }
    

    This method makes use of the formula available on Wikipedia here. This is the important distinction I needed to change.

    These three methods allow the calculation of the contrast ratio between two colours.