作者 | Peter Huber
译者 | 王强
策划 | 丁晓昀
我不知道你们是什么情况,但我自己在过去多年中都因为.NET 色彩(Colors)类中可用的色彩数量有限而头痛不已,为此我试图用 ColorPickers 获得匹配的色彩并努力去理解各种色彩模型。为了让我的生活轻松一些,我写了几个小方法可以把任何色彩改成黑白的,还有一个方法可以混合色彩。有了这些方法后,我得到了匹配度很高的色彩,有点像 GradientBrush 中的渐变效果。
然后我给自己找了个麻烦,就是写这篇文章来帮助其他人使用我的方法。为了解释这些方法的原理,我别无选择,只能详细调查背后到底发生了什么。所以这篇文章主要是关于色彩、色彩模型、色调、亮度之类的东西,但解释它们时用的是让软件开发者不需要数学或物理专业背景就能理解的简单术语。
如果你已经对色彩有了扎实的了解,你可以直接跳到“精确生成你自己的色彩”这一章,那里有实际的代码。
色彩空间 HSB:色调、饱和度和亮度
我们可能都知道,电脑屏幕上的色彩是由像素生成的,每个像素由 3 个点组成,分别发出红、绿、蓝三色光,所以它们被叫做 R、G、B 像素点。然而,这里已经有了第一个误解,因为在实际情况下 G 不是 Colors.Green,而是 Colors.Lime(黄绿)。人眼对色彩有三种不同的感受器,所以人们选择了 R、G 和 B 的色彩(色调)来与这些感受器很好地匹配起来,这里匹配的是黄绿而不是“正常”的绿色。有了 RGB 像素,显示器可以生成人眼可以感受到的大多数色彩——当然只是大多数。与太阳等光源相比,这些色彩的“强度”(亮度)相当有限。
每种色彩都是由 3 个点各自发出的光的多少来定义的。一个点的数值可以介于 0(不发光)和 255(或十六进制的 0xFF)之间,255 意味着全强度发光。为了看到其中一种原色,例如红色,R 要被设置为 255,G 和 B 被设置为 0,这样就可以得到最亮的红色。如果我们想要一个暗一些的红色,只需降低 R 的值即可。一旦 R 为 0,得到的色彩就是黑色的,因为没有任何点发射出任何光线。这是一个改变色彩亮度的例子,而亮度是每一种色彩都有的三个属性之一。有趣的是,亮度的定义不是从 0 到 255,而是从 0 到 1 或 0 到 100%。
色彩的另一个属性被称为色调。色调为黄色、橙色、红色等色彩分配了不同的数字。当 3 个点中的一个是 255,一个是 0,而“中间”(第三个)为任何数值时,我们可以生成最纯粹的色调。例如,R 225, G 255, B 0 结合了红色和绿色,其结果是?奇怪的是,其结果是黄色!原因是,当两盏灯照在同一个地方时,这个地方会变亮而不是变暗。如果我们把 R、G 和 B 混合在一起,都以最大亮度发光,我们就会得到白色的光。这与绘画的色彩正好相反。如果我们把许多绘画的色彩混合在一起,就会得到一些深灰色的东西,很难看。
R:FF G:FF B:00 = 黄色
R:00 G:FF B:FF = 青色
R:FF G:00 B:FF = 品红
顺便说一下,这些是显示器可以生成的最亮的色彩,因为它们使用了 2 个全亮的点,而红、绿、蓝色只使用了 1 个点。对于其他所有色调,亮度第二强的点发出的亮度低于 255。正是这个既不是 255 也不是 0 的“中间”点定义了色调。将这个中间点的亮度从 0 缓慢递增到 255,我们可以得到比如说在红色(FF0000)和黄色(FFF00)之间的所有色调。总共有 6 种这样的转换:
- 255 红色和一些绿色:红到黄
- 255 红色和一些蓝色:红到紫
- 255 绿色和一些红色:绿到黄
- 255 绿色和一些蓝色:绿到蓝绿
- 255 蓝色和一些绿色:蓝到蓝绿
- 255 蓝色和一些红色:蓝到紫
当我们逐渐改变 R、G 或 B 时,会得到类似彩虹的东西。
左边和右边边界的色彩是红色。由于这个原因,彩虹常常被画成一个圆形。色调是以度数来定义的,红色为 0 和 360 度。
第三个色彩属性被称为饱和度。到目前为止,我们只处理了完全饱和的色彩,即最暗的点亮度是 0。如果我们想让一个完全饱和的色彩更亮,使其最终接近白色,我们需要降低饱和度,按比例增加所有三个点的亮度,使其接近 255。要把饱和度从 100% 降低到 50%,我们必须把现在的值与 255 之间的差值减半。举例:
代码语言:javascript复制现值:R 255, G 128, B 0
将饱和度从 1 降低到 0.5
代码语言:javascript复制新的 R 值 = 255 0.5 * (255-255) = 255
代码语言:javascript复制新的 G 值 = 128 0.5 * (255-128) = 192(或 191,取决于四舍五入的情况)
代码语言:javascript复制新的 B 值 = 0 0.5 * (255-0) = 128(或 127,取决于四舍五入的情况)
将饱和度从 1 降低到 0(= 白色)。
代码语言:javascript复制新的 R 值 = 255 1 * (255-255) = 255
代码语言:javascript复制新的 G 值 = 128 1 * (255-128) = 255
代码语言:javascript复制新的 B 值 = 0 1 * (255-0) = 255
代码语言:javascript复制红色从 100% 的饱和度降到 0%:
现在我们可以再画一次彩虹,加入一些饱和度和亮度的变化。X 轴增加了从 0 到 360 的色调。在中间的是以 100% 的饱和度和 100% 的亮度显示的每个色调。在上半部分,亮度保持在 100%,饱和度降低到 0%,这就形成了白色。在下半部分,饱和度保持 100%,亮度减少到 0%,这就形成了黑色。
注意黄色、青色和品红在变成白色或黑色之前比其他色调能保持更长时间。它们是最强的色彩,因为有 2 个点在全亮度闪耀。
一个接近 0% 饱和度和 100% 亮度的色调看起来是白色的。白色的值是 FFFFF,色调和饱和度是未定义的。
一个饱和度为 100%、亮度接近 0% 的色调看起来是黑色的。黑色的值是 0000,色调和饱和度都没有定义。
一个所有 3 个点都以相同强度发光的色彩看起来是灰色的。一个可能的值是 808080。
注意:对于灰色(即 R、G 和 B 有相同的值),色调和饱和度都没有定义,只有亮度有意义的值。我们也可以说,黑色、灰色和白色都不是色彩。黑色 0000 的亮度为 0,白色 FFFFF 的亮度为 1。仅靠亮度来控制白色、灰色和黑色的外观有一个奇怪的后果,我们可以在下一张图中看到。
我们现在已经涵盖了显示器可以显示的所有色彩了吗?实际上,我们只展示了所有可能的 R、G 和 B 组合中的不到 1%,也就是说,只有那些一个点是 255(100% 亮度)或一个点是 0(100% 饱和度)的组合。比方说,我们首先将 FF8000(一种橙红色)的饱和度改为 50%,得到 FFC080。当我们再把亮度改为 50% 时,得到 806040。现在的色调仍然是橙红色,但色彩更接近于深灰色。
下面是一张包含所有可能的饱和度和亮度组合的红色图片。
我想这是本文中最令人困惑的图片。基本上,我想把 Y 轴(从上到下)上的色彩从红色变成黑色,意味着亮度从 1 到 0,而 X 轴(从左到右)上的色彩从红色变成白色,意味着饱和度从 1 到 0,我本来以为白色和黑色也会混合,右下角会变成灰色,但情况并非如此。只要 R、G、B 有相同的值,色调和饱和度就失去了意义。只有亮度对白色、灰色和黑色(右边界)的色彩有影响。更糟糕的是整个下边界只有黑色,因为一旦亮度为 0,色调和饱和度又变得毫无意义了。
如果你也感到困惑,我们就是一伙儿的了。但这就是 HSB 色彩方案的工作方式。当你只操作 RGB 值时,很难说出结果会是什么样子(还记得 R 和 G 混合的结果是黄色吗?)。在 HSB 色彩空间中操作色彩时,只要你只改变饱和度和亮度,黄色就一直是黄色,直到亮度变成 1(白色)或 0(黑色),这时色调和饱和度就会消失。
HSL 色彩空间
还有一个色彩空间叫做 HSL——色调(Hue)、饱和度(Saturation)和亮度(Luminosity)。它的色调与 HSB 相同,但饱和度不朝向白色,而是朝向灰色;光亮度则从 0= 黑色,0.5= 灰色到 1= 白色。HSL 在从黑白电视到彩色电视的过渡过程中非常有用。黑白电视只显示 L 值,而彩色电视则使用 HSL。
取色器
在过去,我总是很难理解取色器是如何工作的,不知道为什么它们有时会失败。现在了解了色调、饱和度和亮度,以及它们与 RGB 色彩的关系后,取色器也就更容易理解了。
PowerPoint 2010 取色器
PowerPoint 使用 HSL 色彩空间。在色彩选择区,它们水平显示所有的色调,垂直显示饱和度。在 HSL 色彩空间中,饱和度为 0 是灰色的,因此完整的下边界是灰色的。右边是一个滚动条,可以改变亮度,0 表示黑色,128 表示灰色,255 表示白色(它不使用 0-100%,而是 0-255)。在 0 和 255 时,色调和饱和度失去了意义。最纯粹的色彩是在亮度 128。
例如,在 HSL 中选择一个蓝色,然后将亮度降低到 0,并切换到 RGB 显示,正确显示为 0,0,0。把 R 增加一点,然后再设置为 0,再切换回 HSV,现在显示的是色调和饱和度为 0,当然,这应该是未定义的。色调为 0 意味着红色,但黑色没有色调。色彩区域的指针仍然显示最初选择的蓝色,而不是色调为 0 的红色。这不仅让我感到困惑,而且当我不断地鼓捣黑色、灰色和白色,并在 RGB 和 HSL 视图之间切换时,我的 PowerPoint 终于崩溃了。
Paint.net 4.2 取色器
事实证明,paint.net 的取色器用起来更容易。他们使用的是 HSV 色彩模型,这与 HSB 色彩模型相同,他们只是把亮度这个词改为体积(volume)。我很欣赏的一点是他们在同一个窗口中显示 RGB 值和 HSV 值,这使我更容易理解改变一个 HSV 值是如何影响 RGB 值的。它在色彩圈的边界上显示所有可用的色调,中间是白色,意味着 0% 的饱和度。要使色彩变深,必须改变体积(亮度)参数的滑块。当然,它的黑色、灰色和白色的色调也为零,但至少在我鼓捣这些数值时它没有崩溃。
WinUI 取色器
可悲的事实是,WPF 没有取色器。这很像微软多年前放弃了 WPF,试图强迫我们改为写 UWP 应用程序的情况。许多开发者会说“不,谢谢你”,并留在了 WPF。所以现在微软正在引入 XAML,允许 WPF 项目使用“较新”的控件,取色器就是一个例子——可这些控件从一开始就应该包含在 WPF 中才对。我还没有在项目中用过 WinUI 取色器,但我在 XAML 控件库中运行它是这样的:
它的工作原理有点像 PowerPoint 中的取色器,但使用 HSV(HSB)代替,这意味着色彩区域的下部是白色的,而不是灰色的。下方的滚动条改变 V 值(亮度)。当设置为黑色时,色调和饱和度保持其最新值,即使后来在色彩区域选择了不同的色彩也一样。当值(亮度)增加时,色彩区的圆圈就会跳回原来的色调。这样很奇怪,但至少没有崩溃。
Web 取色器
下面是一个基于互联网的取色器的例子,在 HTML 5 的 input 标签中定义:
代码语言:javascript复制<input type="color">
HTML 只定义了 input 标签的功能,取色器的实际外观取决于浏览器。下面是 Chrome 浏览器的截图。
它使用一个彩虹滚动条来选择色调。上面的矩形在所有可能的饱和度和亮度组合中显示该色调。关于如何计算数值的详细解释,请看前面关于 HSB 的解释,它的情况几乎是一样的,只是白角和红角换了位置。得到的值可以显示为 RGB(整数或十六进制)和 HSL。
.NET 的色彩类
色彩(Colors)类提供了一些标准色彩。它们是由委员会将一些不同的色彩方案混合起来选择出来的,有时结果很奇怪。例如,Colors.Gray 比 Colors.DarkGray 要深一些。很奇怪,对吗?
或者 2 个不同的名字代表的其实是同一种色彩,比如 Aqua(00FFFF)和 Cyan(00FFFF)或者 Fuchsia(FF00FF)和 Magenta(FF00FF)。不幸的是,Colors 帮助页面是按字母顺序显示色彩的,如果你知道名字就很容易找到它们,但要分辨出哪些色彩彼此相近或相配却非常困难。
所以我花了一些时间,按色调垂直排序,然后按亮度和饱和度水平排序。这里的结果列出了与 Colors 帮助页面中完全相同的色彩:
精确生成定制色彩
让色彩变亮或变暗(降低饱和度和 / 或亮度)
当我设计一个新的应用程序并决定要使用的色彩方案时,我通常不能使用色彩类提供的调色板。通常情况下,我需要带有阴影的相同色调(不同的饱和度和亮度)。要做到这一点只需几行代码就能搞定了。下面是降低任何色彩的饱和度(使之更亮)或降低亮度(使之更暗)的方法。
代码语言:javascript复制/// <summary>
/// Makes the color lighter if factor>0 and darker if factor<0. 1 returns white, -1 returns
/// black.
/// </summary>
public static Color GetBrighterOrDarker(this Color color, double factor) {
if (factor<-1) throw new Exception($"Factor {factor} must be greater equal -1.");
if (factor>1) throw new Exception($"Factor {factor} must be smaller equal 1.");
if (factor==0) return color;
if (factor<0) {
//make color darker, changer brightness
factor = 1;
return Color.FromArgb(
color.A,
(byte)(color.R*factor),
(byte)(color.G*factor),
(byte)(color.B*factor));
} else {
//make color lighter, change saturation
return Color.FromArgb(
color.A,
(byte)(color.R (255-color.R)*factor),
(byte)(color.G (255-color.G)*factor),
(byte)(color.B (255-color.B)*factor));
}
}
令人惊讶的是只用几行代码就可以改变饱和度和亮度。有点困难的是如何正确计算饱和度,而这个方法就很方便。
红、绿、蓝,系数为 -1 到 1:
为了得到更亮的色彩,不要使用绿色,因为绿色不是 100% 的饱和度——而要使用黄色、品红和青色。
请注意,先应用 0.5 的系数,然后再应用 -0.5 的系数是不会得到原来色彩的。第一次调用改变的是饱和度,第二次调用改变的是亮度。
我喜欢使用这种方法的原因是:
- 我可以用小幅度而可控的步骤增加、减少变化,并在 GUI 中看到结果。
- 我可以很容易地创建阴影和高光,它们应该有相同的色调,但饱和度和亮度则不同。
混合色调
通常情况下,一个用户界面不应该使用太多的色调,有几个色调并从中混合一些色调可能就够了。下面两种方法可以达到这个目的,第一种是将两种色彩一半一半地混合,第二种是让一种色彩多于另一种色彩。
代码语言:javascript复制/// <summary>
/// Mixes 2 colors equally
/// </summary>
public static Color Mix(this Color color1, Color color2) {
return Mix(color1, 0.5, color2);
}
/// <summary>
/// Mixes factor*color1 with (1-factor)*color2.
/// </summary>
public static Color Mix(this Color color1, double factor, Color color2) {
if (factor<0) throw new Exception($"Factor {factor} must be greater equal 0.");
if (factor>1) throw new Exception($"Factor {factor} must be smaller equal 1.");
if (factor==0) return color2;
if (factor==1) return color1;
var factor1 = 1 - factor;
return Color.FromArgb(
(byte)((color1.A * factor color2.A * factor1)),
(byte)((color1.R * factor color2.R * factor1)),
(byte)((color1.G * factor color2.G * factor1)),
(byte)((color1.B * factor color2.B * factor1)));
}
代码语言:javascript复制
这就是生成良好匹配的色彩所需要的一切操作。第一张图片显示了每种“主”色如何与其他“主”色进行不同程度的混合,同样是红、绿和蓝:
然而,如果你用黄色、品色和青色来代替可能会更好。
在这里,我真的觉得这些色彩比混合红、绿、蓝更漂亮。当然,它们可能太纯粹了。GUI 经常使用灰暗的色调,使用 GetBrighterOrDarker() 可以很容易地在混合后做出灰暗的色调。
获取 RGB 色彩的色调、饱和度和亮度
我还做了一些写这篇文章时所需要的方法,可能也很有用。第一个方法是计算一个 RGB 色彩的色调、饱和度和亮度。
代码语言:javascript复制/// <summary>
/// Returns the hue, saturation and brightness of color
/// </summary>
public static (int Hue, double Saturation, double Brightness)GetHSB(this Color color) {
int max = Math.Max(color.R, Math.Max(color.G, color.B));
int min = Math.Min(color.R, Math.Min(color.G, color.B));
int hue = 0;//for black, gray or white, hue could be actually any number, but usually 0 is
//assign, which means red
if (max-min!=0) {
//not black, gray or white
int maxMinDif = max-min;
if (max==color.R) {
#pragma warning disable IDE0045 // Convert to conditional expression
if (color.G>=color.B) {
#pragma warning restore IDE0045
hue = 60 * (color.G-color.B)/maxMinDif;
} else {
hue = 60 * (color.G-color.B)/maxMinDif 360;
}
} else if (max==color.G) {
hue = 60 * (color.B-color.R)/maxMinDif 120;
} else if(max == color.B) {
hue = 60 * (color.R-color.G)/maxMinDif 240;
}
}
double saturation = (max == 0) ? 0.0 : (1.0-((double)min/(double)max));
return (hue, saturation, (double)max/0xFF);
}
我从“CodeProject:在.NET 中操纵色彩第 1 部分”中复制了这段代码(其实它赢得了“2007 年 5 月最佳 C# 文章”),并对其做了点微小的“改进”。例如,我觉得整数 0 到 360 足以列举所有的色调。原代码使用浮点数,所以你可以有无限多的色调。这可能会让计算不那么容易受到四舍五入错误的影响,但我想人们看不出这有什么不同。
将任何色彩的饱和度和亮度提高到 100%
在我选择匹配色彩的方法中,最好从饱和度和亮度为 100% 的"纯色"开始,然后再进行混合,使其变得更深或更亮。下面的方法接收任何 RGB 色彩,并返回一个具有相同色调的 RGB 色彩,但饱和度和亮度为 100%。
代码语言:javascript复制/// <summary>
/// Returns a color with the same hue, but brightness and saturation increased to 100%.
/// </summary>
public static Color ToFullColor(this Color color) {
//step 1: increase brightness to 100%
var max = Math.Max(color.R, Math.Max(color.G, color.B));
var min = Math.Min(color.R, Math.Min(color.G, color.B));
if (max==min) {
//for black, gray or white return white
return Color.FromArgb(color.A, 0xFF, 0xFF, 0xFF);
}
double rBright = (double)color.R * 255 / max;
double gBright = (double)color.G * 255 / max;
double bBright = (double)color.B * 255 / max;
//step2: increase saturation to 100%
//lower smallest R, G, B component to zero and adjust second smallest color accordingly
//p = (smallest R, G, B component) / 255
//(255-FullColor.SecondComponent) * p FullColor.SecondComponent = color.SecondComponent
//FullColor.SecondComponent = (color.SecondComponent-255p)/(1-p)
if (color.R==max) {
if (color.G==min) {
double p = gBright / 255;
return Color.FromArgb(color.A, 0xFF, 0, (byte)((bBright-gBright)/(1-p)));
} else {
double p = bBright / 255;
return Color.FromArgb(color.A, 0xFF, (byte)((gBright-bBright)/(1-p)), 0);
}
} else if (color.G==max) {
if (color.R==min) {
double p = rBright / 255;
return Color.FromArgb(color.A, 0, 0xFF, (byte)((bBright-rBright)/(1-p)));
} else {
double p = bBright / 255;
return Color.FromArgb(color.A, (byte)((rBright-bBright)/(1-p)), 0xFF, 0);
}
} else {
if (color.R==min) {
double p = rBright / 255;
return Color.FromArgb(color.A, 0, (byte)((gBright-rBright)/(1-p)), 0xFF);
} else {
double p = bBright / 255;
return Color.FromArgb(color.A, (byte)((rBright-bBright)/(1-p)), 0, 0xFF);
}
}
}
这个方法是我自己写的。这里的数学要求比较高,我希望我的计算是正确的。你可以使用任何取色器来轻松验证它。如果你发现任何不一致的地方,请告诉我。
作者简介:
Peter Huber 是 50 年前在 HP35 上开始学习编程的。然后他从 PL1、Assembler、Pascal、Modula、Java 转到 C#,并且喜欢上 C# 语言已经有 15 年了。他已经在新加坡退休,他的大部分时间都花在编写单用户 WPF 应用程序上。
原文链接:
https://www.infoq.com/articles/colors-dotnet-guide/