Skip to content

Instantly share code, notes, and snippets.

@UweKeim
Last active July 24, 2024 09:00
Show Gist options
  • Save UweKeim/fb7f829b852c209557bc49c51ba14c8b to your computer and use it in GitHub Desktop.
Save UweKeim/fb7f829b852c209557bc49c51ba14c8b to your computer and use it in GitHub Desktop.
HSL color, HSB color and RGB color types, as well as conversion methods between them in C# and .NET
namespace ZetaColorEditor.Colors;
using System;
using System.Drawing;
/// <summary>
/// Provides color conversion functionality.
/// </summary>
/// <remarks>
/// http://en.wikipedia.org/wiki/HSV_color_space
/// http://www.easyrgb.com/math.php?MATH=M19#text19
/// </remarks>
internal static class ColorConverting
{
public static RgbColor ColorToRgb(
Color color)
{
return new(color.R, color.G, color.B, color.A);
}
public static Color RgbToColor(
RgbColor rgb)
{
return Color.FromArgb(rgb.Alpha, rgb.Red, rgb.Green, rgb.Blue);
}
public static HsbColor RgbToHsb(
RgbColor rgb)
{
// _NOTE #1: Even though we're dealing with a very small range of
// numbers, the accuracy of all calculations is fairly important.
// For this reason, I've opted to use double data types instead
// of float, which gives us a little bit extra precision (recall
// that precision is the number of significant digits with which
// the result is expressed).
var r = rgb.Red / 255d;
var g = rgb.Green / 255d;
var b = rgb.Blue / 255d;
var minValue = getMinimumValue(r, g, b);
var maxValue = getMaximumValue(r, g, b);
var delta = maxValue - minValue;
double hue = 0;
double saturation;
var brightness = maxValue * 100;
if (Math.Abs(maxValue - 0) < 0.00001 || Math.Abs(delta - 0) < 0.00001)
{
hue = 0;
saturation = 0;
}
else
{
// _NOTE #2: FXCop insists that we avoid testing for floating
// point equality (CA1902). Instead, we'll perform a series of
// tests with the help of 0.00001 that will provide
// a more accurate equality evaluation.
if (Math.Abs(minValue - 0) < 0.00001)
{
saturation = 100;
}
else
{
saturation = delta / maxValue * 100;
}
if (Math.Abs(r - maxValue) < 0.00001)
{
hue = (g - b) / delta;
}
else if (Math.Abs(g - maxValue) < 0.00001)
{
hue = 2 + (b - r) / delta;
}
else if (Math.Abs(b - maxValue) < 0.00001)
{
hue = 4 + (r - g) / delta;
}
}
hue *= 60;
if (hue < 0)
{
hue += 360;
}
return new(
hue,
saturation,
brightness,
rgb.Alpha);
}
public static HslColor RgbToHsl(
RgbColor rgb)
{
var varR = rgb.Red / 255.0; //Where RGB values = 0 ÷ 255
var varG = rgb.Green / 255.0;
var varB = rgb.Blue / 255.0;
var varMin = getMinimumValue(varR, varG, varB); //Min. value of RGB
var varMax = getMaximumValue(varR, varG, varB); //Max. value of RGB
var delMax = varMax - varMin; //Delta RGB value
double h;
double s;
var l = (varMax + varMin) / 2;
if (Math.Abs(delMax - 0) < 0.00001) //This is a gray, no chroma...
{
h = 0; //HSL results = 0 ÷ 1
s = 0;
// UK:
// s = 1.0;
}
else //Chromatic data...
{
if (l < 0.5)
{
s = delMax / (varMax + varMin);
}
else
{
s = delMax / (2.0 - varMax - varMin);
}
var delR = ((varMax - varR) / 6.0 + delMax / 2.0) / delMax;
var delG = ((varMax - varG) / 6.0 + delMax / 2.0) / delMax;
var delB = ((varMax - varB) / 6.0 + delMax / 2.0) / delMax;
if (Math.Abs(varR - varMax) < 0.00001)
{
h = delB - delG;
}
else if (Math.Abs(varG - varMax) < 0.00001)
{
h = 1.0 / 3.0 + delR - delB;
}
else if (Math.Abs(varB - varMax) < 0.00001)
{
h = 2.0 / 3.0 + delG - delR;
}
else
{
// Uwe Keim.
h = 0.0;
}
if (h < 0.0)
{
h += 1.0;
}
if (h > 1.0)
{
h -= 1.0;
}
}
// --
return new(
h * 360.0,
s * 100.0,
l * 100.0,
rgb.Alpha);
}
public static RgbColor HsbToRgb(
HsbColor hsb)
{
double red = 0, green = 0, blue = 0;
double h = hsb.Hue;
var s = (double)hsb.Saturation / 100;
var b = (double)hsb.Brightness / 100;
if (Math.Abs(s - 0) < 0.00001)
{
red = b;
green = b;
blue = b;
}
else
{
// the color wheel has six sectors.
var sectorPosition = h / 60;
var sectorNumber = (int)Math.Floor(sectorPosition);
var fractionalSector = sectorPosition - sectorNumber;
var p = b * (1 - s);
var q = b * (1 - s * fractionalSector);
var t = b * (1 - s * (1 - fractionalSector));
// Assign the fractional colors to r, g, and b
// based on the sector the angle is in.
switch (sectorNumber)
{
case 0:
red = b;
green = t;
blue = p;
break;
case 1:
red = q;
green = b;
blue = p;
break;
case 2:
red = p;
green = b;
blue = t;
break;
case 3:
red = p;
green = q;
blue = b;
break;
case 4:
red = t;
green = p;
blue = b;
break;
case 5:
red = b;
green = p;
blue = q;
break;
}
}
var nRed = Convert.ToInt32(red * 255);
var nGreen = Convert.ToInt32(green * 255);
var nBlue = Convert.ToInt32(blue * 255);
return new(nRed, nGreen, nBlue, hsb.Alpha);
}
public static RgbColor HslToRgb(
HslColor hsl)
{
double red, green, blue;
var h = hsl.PreciseHue / 360.0;
var s = hsl.PreciseSaturation / 100.0;
var l = hsl.PreciseLight / 100.0;
if (Math.Abs(s - 0.0) < 0.00001)
{
red = l;
green = l;
blue = l;
}
else
{
double var2;
if (l < 0.5)
{
var2 = l * (1.0 + s);
}
else
{
var2 = l + s - s * l;
}
var var1 = 2.0 * l - var2;
red = hue2Rgb(var1, var2, h + 1.0 / 3.0);
green = hue2Rgb(var1, var2, h);
blue = hue2Rgb(var1, var2, h - 1.0 / 3.0);
}
// --
var nRed = Convert.ToInt32(red * 255.0);
var nGreen = Convert.ToInt32(green * 255.0);
var nBlue = Convert.ToInt32(blue * 255.0);
return new(nRed, nGreen, nBlue, hsl.Alpha);
}
private static double hue2Rgb(
double v1,
double v2,
double vH)
{
if (vH < 0.0)
{
vH += 1.0;
}
if (vH > 1.0)
{
vH -= 1.0;
}
if (6.0 * vH < 1.0)
{
return v1 + (v2 - v1) * 6.0 * vH;
}
if (2.0 * vH < 1.0)
{
return v2;
}
if (3.0 * vH < 2.0)
{
return v1 + (v2 - v1) * (2.0 / 3.0 - vH) * 6.0;
}
return v1;
}
/// <summary>
/// Determines the maximum value of all of the numbers provided in the
/// variable argument list.
/// </summary>
private static double getMaximumValue(
params double[] values)
{
var maxValue = values[0];
if (values.Length >= 2)
{
for (var i = 1; i < values.Length; i++)
{
var num = values[i];
maxValue = Math.Max(maxValue, num);
}
}
return maxValue;
}
/// <summary>
/// Determines the minimum value of all of the numbers provided in the
/// variable argument list.
/// </summary>
private static double getMinimumValue(
params double[] values)
{
var minValue = values[0];
if (values.Length >= 2)
{
for (var i = 1; i < values.Length; i++)
{
var num = values[i];
minValue = Math.Min(minValue, num);
}
}
return minValue;
}
}
<Project>
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
namespace ZetaColorEditor.Colors;
using System.Drawing;
/// <summary>
/// Represents a HSV (=HSB) color space.
/// http://en.wikipedia.org/wiki/HSV_color_space
/// </summary>
[PublicAPI]
public sealed class HsbColor(double hue,
double saturation,
double brightness,
int alpha)
{
/// <summary>
/// Gets or sets the hue. Values from 0 to 360.
/// </summary>
public double PreciseHue { get; } = hue;
/// <summary>
/// Gets or sets the saturation. Values from 0 to 100.
/// </summary>
public double PreciseSaturation { get; } = saturation;
/// <summary>
/// Gets or sets the brightness. Values from 0 to 100.
/// </summary>
public double PreciseBrightness { get; } = brightness;
public int Hue => Convert.ToInt32(PreciseHue);
public int Saturation => Convert.ToInt32(PreciseSaturation);
public int Brightness => Convert.ToInt32(PreciseBrightness);
/// <summary>
/// Gets or sets the alpha. Values from 0 to 255.
/// </summary>
public int Alpha { get; } = alpha;
public static HsbColor FromColor(
Color color)
{
return ColorConverting.ColorToRgb(color).ToHsbColor();
}
public static HsbColor FromRgbColor(
RgbColor color)
{
return color.ToHsbColor();
}
public static HsbColor FromHsbColor(
HsbColor color)
{
return new(color.PreciseHue, color.PreciseSaturation, color.PreciseBrightness, color.Alpha);
}
public static HsbColor FromHslColor(
HslColor color)
{
return FromRgbColor(color.ToRgbColor());
}
public override string? ToString()
{
return $@"Hue: {Hue}; saturation: {Saturation}; brightness: {Brightness}.";
}
public Color ToColor()
{
return ColorConverting.HsbToRgb(this).ToColor();
}
public RgbColor ToRgbColor()
{
return ColorConverting.HsbToRgb(this);
}
public HsbColor ToHsbColor()
{
return new(PreciseHue, PreciseSaturation, PreciseBrightness, Alpha);
}
public HslColor ToHslColor()
{
return ColorConverting.RgbToHsl(ToRgbColor());
}
public override bool Equals(object? obj)
{
var equal = false;
if (obj is HsbColor color)
{
if (Math.Abs(PreciseHue - color.PreciseHue) < 0.00001 &&
Math.Abs(PreciseSaturation - color.PreciseSaturation) < 0.00001 &&
Math.Abs(PreciseBrightness - color.PreciseBrightness) < 0.00001 &&
Alpha == color.Alpha)
{
equal = true;
}
}
return equal;
}
public override int GetHashCode()
{
return $@"H:{Hue}-S:{Saturation}-B:{Brightness}-A:{Alpha}".GetHashCode();
}
}
namespace ZetaColorEditor.Colors;
using System.Drawing;
/// <summary>
/// Represents a HSL color space.
/// http://en.wikipedia.org/wiki/HSV_color_space
/// </summary>
[PublicAPI]
public sealed class HslColor(
double hue,
double saturation,
double light,
int alpha)
{
/// <summary>
/// Gets the hue. Values from 0 to 360.
/// </summary>
public int Hue => Convert.ToInt32(PreciseHue);
/// <summary>
/// Gets the precise hue. Values from 0 to 360.
/// </summary>
public double PreciseHue { get; } = hue;
/// <summary>
/// Gets the saturation. Values from 0 to 100.
/// </summary>
public int Saturation => Convert.ToInt32(PreciseSaturation);
/// <summary>
/// Gets the precise saturation. Values from 0 to 100.
/// </summary>
public double PreciseSaturation { get; } = saturation;
/// <summary>
/// Gets the light. Values from 0 to 100.
/// </summary>
public int Light => Convert.ToInt32(PreciseLight);
/// <summary>
/// Gets the precise light. Values from 0 to 100.
/// </summary>
public double PreciseLight { get; } = light;
/// <summary>
/// Gets the alpha. Values from 0 to 255
/// </summary>
public int Alpha { get; } = alpha;
public static HslColor FromColor(Color color)
{
return ColorConverting.RgbToHsl(ColorConverting.ColorToRgb(color));
}
public static HslColor FromRgbColor(RgbColor color)
{
return color.ToHslColor();
}
public static HslColor FromHslColor(HslColor color)
{
return new(
color.PreciseHue,
color.PreciseSaturation,
color.PreciseLight,
color.Alpha);
}
public static HslColor FromHsbColor(HsbColor color)
{
return FromRgbColor(color.ToRgbColor());
}
public override string? ToString()
{
return Alpha < 255
? $@"hsla({Hue}, {Saturation}%, {Light}%, {Alpha / 255f})"
: $@"hsl({Hue}, {Saturation}%, {Light}%)";
}
public Color ToColor()
{
return ColorConverting.HslToRgb(this).ToColor();
}
public RgbColor ToRgbColor()
{
return ColorConverting.HslToRgb(this);
}
public HslColor ToHslColor()
{
return this;
}
public HsbColor ToHsbColor()
{
return ColorConverting.RgbToHsb(ToRgbColor());
}
public override bool Equals(object? obj)
{
var equal = false;
if (obj is HslColor color)
{
if (Math.Abs(Hue - color.PreciseHue) < 0.00001 &&
Math.Abs(Saturation - color.PreciseSaturation) < 0.00001 &&
Math.Abs(Light - color.PreciseLight) < 0.00001 &&
Alpha == color.Alpha)
{
equal = true;
}
}
return equal;
}
public override int GetHashCode()
{
return $@"H:{PreciseHue}-S:{PreciseSaturation}-L:{PreciseLight}-A:{Alpha}".GetHashCode();
}
}
namespace ZetaColorEditor.Colors;
using System.Drawing;
/// <summary>
/// Represents a RGB color space.
/// http://en.wikipedia.org/wiki/HSV_color_space
/// </summary>
[PublicAPI]
public sealed class RgbColor(
int red,
int green,
int blue,
int alpha)
{
/// <summary>
/// Gets or sets the red component. Values from 0 to 255.
/// </summary>
public int Red { get; } = red;
/// <summary>
/// Gets or sets the green component. Values from 0 to 255.
/// </summary>
public int Green { get; } = green;
/// <summary>
/// Gets or sets the blue component. Values from 0 to 255.
/// </summary>
public int Blue { get; } = blue;
/// <summary>
/// Gets or sets the alpha component. Values from 0 to 255.
/// </summary>
public int Alpha { get; } = alpha;
public static RgbColor FromColor(
Color color)
{
return ColorConverting.ColorToRgb(color);
}
public static RgbColor FromRgbColor(
RgbColor color)
{
return new(color.Red, color.Green, color.Blue, color.Alpha);
}
public static RgbColor FromHsbColor(
HsbColor color)
{
return color.ToRgbColor();
}
public static RgbColor FromHslColor(
HslColor color)
{
return color.ToRgbColor();
}
public override string? ToString()
{
return Alpha < 255 ? $@"rgba({Red}, {Green}, {Blue}, {Alpha / 255d})" : $@"rgb({Red}, {Green}, {Blue})";
}
public Color ToColor()
{
return ColorConverting.RgbToColor(this);
}
public RgbColor ToRgbColor()
{
return this;
}
public HsbColor ToHsbColor()
{
return ColorConverting.RgbToHsb(this);
}
public HslColor ToHslColor()
{
return ColorConverting.RgbToHsl(this);
}
public override bool Equals(object? obj)
{
var equal = false;
if (obj is RgbColor color)
{
if (Red == color.Red && Blue == color.Blue && Green == color.Green && Alpha == color.Alpha)
{
equal = true;
}
}
return equal;
}
public override int GetHashCode()
{
return $@"R:{Red}-G:{Green}-B:{Blue}-A:{Alpha}".GetHashCode();
}
}
@hacklex
Copy link

hacklex commented Jul 13, 2024

@UweKeim In short, you use 0.00001 (1e-5) rather than unreasonably small Double.Epsilon (5E-324).

As I tried using this code, I quickly noticed there are several logical flaws, one seemingly due to a overlooked copy/paste, another due to slightly inaccurate handling of angular quantity wrapping. So I took the liberty of rewriting some of the code. Now it uses certain modern C# features, takes way less vertical screen space, and is at least a bit better when wrapping around the hue value of 360 in the HSB color space.

You may take a look at this extracted gist -- or just straight copy it if you ever need it. What most probably were wrong in the initial code, is marked by comments, see lines 187 and 342. Refer to the documentation if unsure.

Also, I removed the hyperlinks to the site that appears to be already dead, and updated the Wiki links.

Also, take a look at the IsVisuallyEqualTo(IColor) method, which might be more useful than the regular Equals().

@UweKeim
Copy link
Author

UweKeim commented Jul 14, 2024

That's incredible awesome, @hacklex. Thank you very much for your detailed reply and your code.

@hacklex
Copy link

hacklex commented Jul 14, 2024

Glad you liked it. Also, be advised that your code ignores the alpha channel values in Equals. This might eventually spell trouble, so I advise you to correct that even if you opt to stick with your old version.

I'm now thinking of making a proper github repo out of this. Such color space manipulations seem to be standard enough, and having them all in one place, neatly packaged and well documented, sounds like the right thing to do.

You won't mind my reusing parts of your code for that, right? The github repo will be public and MIT licensed of course.

@UweKeim
Copy link
Author

UweKeim commented Jul 14, 2024

Thanks again, @hacklex, I've updated my source code locally and pasted the new changes here as updated source files.

Hopefully I've fixed most of the issues.

@hacklex
Copy link

hacklex commented Jul 24, 2024

Yes, some are fixed now. From quick review, this still needs fixing.

See my workaround, WrapAround utility function being defined here. If you're not convinced, I can dig up my old code and find the exact example that breaks your version without this check.

You don't mind me creating a public repo for this particular task, do you? I am going to include several parts of your code, refactored to better suit the current C# standards. Naturally, I will mark your code as yours in the comments.

If you don't like the idea, just say so and I will not publish it, or make it private.

@UweKeim
Copy link
Author

UweKeim commented Jul 24, 2024

Thanks, @hacklex. You creating a public repository sounds like an awesome idea. I would be glad and honored 😊.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment