Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API Proposal: Calculate Contrast Color for Improved Accessibility #12588

Open
elachlan opened this issue Dec 5, 2024 · 10 comments
Open

API Proposal: Calculate Contrast Color for Improved Accessibility #12588

elachlan opened this issue Dec 5, 2024 · 10 comments
Labels
api-suggestion (1) Early API idea and discussion, it is NOT ready for implementation area-System.Drawing System.Drawing issues untriaged The team needs to look at this issue in the next triage

Comments

@elachlan
Copy link
Contributor

elachlan commented Dec 5, 2024

Background and motivation

When designing user interfaces, it is essential to ensure text or elements displayed over a background color have sufficient contrast for readability. This is particularly relevant for accessibility compliance (e.g., WCAG). Calculating a contrasting color, such as black or white, based on the background color's luminance, is a common requirement.

Currently, .NET's Color struct does not provide an in-built way to compute a contrast color. This proposal adds a ContrastColor method directly to the Color struct, allowing developers to easily determine the optimal contrasting color (black or white) for any given color.

I currently use a version of this in my applications to help get contrasting text color for different labels/controls where a user can configure its background color (such as the status bar).

Reference: https://stackoverflow.com/questions/1855884/determine-font-color-based-on-background-color

API Proposal

using System.Drawing;

public static class ColorExtensions
{
    /// <summary>
    /// Calculates a WCAG 2.2 compliant contrasting color (black or white) based on the luminance of the current color.
    /// </summary>
    /// <param name="color">The base color.</param>
    /// <returns>Black for bright colors, white for dark colors.</returns>
    public static Color ContrastColor(this Color color);

    /// <summary>
    /// Determines whether the specified foreground color meets or exceeds
    /// the given WCAG contrast ratio when drawn over the specified background color.
    /// </summary>
    /// <param name="background">The background color to test against.</param>
    /// <param name="foreground">The foreground color to verify.</param>
    /// <param name="requiredRatio">
    /// The required WCAG contrast ratio. Defaults to 4.5, which is the standard for normal text.
    /// </param>
    /// <returns>
    /// <c>true</c> if the contrast ratio between <paramref name="background"/> and <paramref name="foreground"/>
    /// is greater than or equal to <paramref name="requiredRatio"/>; otherwise <c>false</c>.
    /// </returns>
    public static bool IsCompliant(Color background, Color foreground, double requiredRatio = RequiredRatio);
}

API Usage

using System.Drawing;

// Suppose you have a background color:
var background = Color.FromArgb(255, 0, 0); // Bright red

// Get a compliant foreground color:
Color foreground = background.ContrastColor();

// Check compliance explicitly if needed:
bool isAccessible = WcagContrastColor.IsCompliant(background, foreground);

Alternative Designs

Standalone Helper Function
Instead of an extension method, a static utility function could be added in a helper class. However, attaching the method directly to Color improves discoverability and API integration.

Configurable Threshold
An additional overload could allow developers to specify a custom luminance threshold, but this would add complexity without significant value for most use cases.

Risks

Perceived Simplicity
While the luminance formula used is standard, it assumes consistent behavior across platforms. Deviations in rendering systems or gamma settings might lead to slight visual discrepancies.

Edge Cases
Fully transparent colors (Color.A = 0) are handled by returning Color.Black as a fallback, which may not align with all design requirements.

Will this feature affect UI controls?

N/A

@elachlan elachlan added api-suggestion (1) Early API idea and discussion, it is NOT ready for implementation untriaged The team needs to look at this issue in the next triage labels Dec 5, 2024
@elachlan
Copy link
Contributor Author

elachlan commented Dec 5, 2024

@JeremyKuhne this one is just something I feel like a lot of devs might need at some point and a built in function would be immensely helpful.

@elachlan
Copy link
Contributor Author

elachlan commented Dec 5, 2024

I did a quick check with chatGPT, it suggests the following changes:

/// <summary>
    /// Calculates a contrasting color (black or white) based on the luminance of the current color.
    /// </summary>
    /// <param name="color">The base color.</param>
    /// <returns>Black for bright colors, white for dark colors.</returns>
    public static Color ContrastColor(this Color color)
    {
        // If fully transparent, return black immediately
        if (color.A == 0)
            return Color.Black;

        // Use integer math for luma calculation to avoid floating-point overhead
        int luma = (299 * color.R + 587 * color.G + 114 * color.B) / 1000;

        // Return black for bright colors, white for dark colors
        return luma > 128 ? Color.Black : Color.White;
    }

And then even faster (maybe)

public static Color ContrastColor(this Color color)
    {
        // Approximate luma calculation with bitwise shifts for integer math
        int luma = (color.R * 77 + color.G * 150 + color.B * 29) >> 8; // Dividing by 256

        // Return black for bright colors, white for dark colors
        return luma > 128 ? Color.Black : Color.White;
    }

even faster (maybe)?

public static Color ContrastColor(this Color color)
    {
        // Get ARGB value once
        int argb = color.ToArgb();

        // Extract components and calculate luminance
        int luma = ((argb >> 16 & 0xFF) * 77 + // Red component
                    (argb >> 8 & 0xFF) * 150 + // Green component
                    (argb & 0xFF) * 29) >> 8;  // Blue component

        // Return black for bright colors, white for dark colors
        return luma > 128 ? Color.Black : Color.White;
    }

@elachlan
Copy link
Contributor Author

elachlan commented Dec 5, 2024

Benchmark Results:
BenchmarkDotNet v0.14.0, Windows 11 (10.0.22631.4460/23H2/2023Update/SunValley3)
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.101
[Host] : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI [AttachedDebugger]
DefaultJob : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

Method Mean Error StdDev Allocated
OriginalContrastColor 3.539 ns 0.0282 ns 0.0250 ns -
OptimizedContrastColor 2.578 ns 0.0149 ns 0.0132 ns -
BitwiseShiftContrastColor 2.142 ns 0.0114 ns 0.0107 ns -
UltimateContrastColor 1.714 ns 0.0178 ns 0.0158 ns -

@elachlan elachlan added the area-System.Drawing System.Drawing issues label Dec 5, 2024
@JeremyKuhne
Copy link
Member

@elachlan it would be good to reference specific requirements. @merriemcgaw or @Tanya-Solyanik might have some relevant specifications to add as well.

@JeremyKuhne
Copy link
Member

even faster (maybe)?

internal void GetRgbValues(out int r, out int g, out int b) is the one that extracts the three colors without multiple lookups. We should probably recommend that we make this public in another API proposal.

@merriemcgaw
Copy link
Member

@elachlan it would be good to reference specific requirements. @merriemcgaw or @Tanya-Solyanik might have some relevant specifications to add as well.

The current WCAG 2.2 requirements can be found here: https://www.w3.org/TR/WCAG22/#contrast-minimum

Love this idea!

@elachlan
Copy link
Contributor Author

elachlan commented Dec 5, 2024

The current API proposal returns either black or white depending on the luminence. I don't think its WCAG 2.0 compliant because it doesn't return other colors.

An update to the function might be something like this:

public static Color WCAGCompliantContrastColor(this Color color)
{
    // Calculate luminance
    double r = color.R / 255.0;
    double g = color.G / 255.0;
    double b = color.B / 255.0;

    r = (r <= 0.03928) ? r / 12.92 : Math.Pow((r + 0.055) / 1.055, 2.4);
    g = (g <= 0.03928) ? g / 12.92 : Math.Pow((g + 0.055) / 1.055, 2.4);
    b = (b <= 0.03928) ? b / 12.92 : Math.Pow((b + 0.055) / 1.055, 2.4);

    double luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;

    // Use a threshold to decide contrast color
    return luminance > 0.5 ? Color.Black : Color.White;
}

But that still returns black or white.

Edit:
Performance Benchmark

Method Mean Error StdDev Allocated
TestUltimateContrastColor 1.742 ns 0.0295 ns 0.0261 ns -
TestWCAGCompliantContrastColorInt 5.158 ns 0.0364 ns 0.0341 ns -

@willibrandon
Copy link
Contributor

willibrandon commented Dec 7, 2024

Hello everyone, I’ve been following this issue and wanted to share some experimental results. I tested an approach that uses a small LUT (lookup table) for gamma correction, removing the need for repetitive MathF.Pow calls. This significantly improves performance while still meeting WCAG requirements.

For example, here are some benchmark results:

Method Mean Error StdDev Allocated
CachedColor_O1WithCachingUnsafe 5.720 ns 0.1316 ns 0.1408 ns -
BlackWhiteContrast_O1WithCachingUnsafe 5.654 ns 0.1261 ns 0.1239 ns -
DynamicContrast_O1WithCachingUnsafe 5.524 ns 0.0766 ns 0.0679 ns -

This approach requires only a tiny, static LUT, yielding O(1) performance with no allocations. If it’s of interest, I’m happy to share more details. Thanks for the ongoing work on accessibility!

@willibrandon
Copy link
Contributor

willibrandon commented Dec 7, 2024

I recommend adding an IsCompliant method to let you easily ensure accessibility.

/// <summary>
/// Determines whether the specified foreground color meets or exceeds
/// the given WCAG contrast ratio when drawn over the specified background color.
/// </summary>
/// <param name="background">The background color to test against.</param>
/// <param name="foreground">The foreground color to verify.</param>
/// <param name="requiredRatio">
/// The required WCAG contrast ratio. Defaults to 4.5, which is the standard for normal text.
/// </param>
/// <returns>
/// <c>true</c> if the contrast ratio between <paramref name="background"/> and <paramref name="foreground"/>
/// is greater than or equal to <paramref name="requiredRatio"/>; otherwise <c>false</c>.
/// </returns>
public static bool IsCompliant(Color background, Color foreground, double requiredRatio = RequiredRatio)

Usage

// Suppose you have a background color:
var background = Color.FromArgb(255, 0, 0); // Bright red

// Get a compliant foreground color:
Color foreground = background.ContrastColor();

// Check compliance explicitly if needed:
bool isAccessible = WcagContrastColor.IsCompliant(background, foreground);

@elachlan
Copy link
Contributor Author

elachlan commented Dec 8, 2024

I've updated the issue description. with IsCompliant.

I am unsure on the LUT, I feel the overhead of populating it might be excessive. My use case doesn't require calling it within a render loop, I am setting the BackgroundColor on a control and then calculating the ForeColor. This happens once for the control.

I could see a timer or async function invoking an update on a controls value, which might change the BackgroundColor and calculate the ForeColor. A good example might be a Tachometer, which is updating rapidly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-suggestion (1) Early API idea and discussion, it is NOT ready for implementation area-System.Drawing System.Drawing issues untriaged The team needs to look at this issue in the next triage
Projects
None yet
Development

No branches or pull requests

4 participants