我时不时地发现自己需要计算互补色。例如,我可能正在 Web 应用程序中制作折线图,或者为报告制作条形图。当发生这种情况时,我希望使用互补色,使线条或条形图之间具有最大的“视觉差异”。
在线计算器在计算两种或三种互补色时可能很有用,但有时我需要更多——例如,可能需要 10 或 15 种。
许多在线资源解释了如何做到这一点并提供了公式,但我认为现在是时候制作一个 Groovy 颜色计算器了。所以请跟随我一起学习。首先,您可能需要安装 Java 和 Groovy。
安装 Java 和 Groovy
Groovy 基于 Java,也需要安装 Java。最新/合适的 Java 和 Groovy 版本可能在您的 Linux 发行版的存储库中。或者您可以按照上面链接中的说明安装 Groovy。
对于 Linux 用户来说,一个不错的替代方案是 SDKMan,它可以获取多个版本的 Java、Groovy 和许多其他相关工具。在本文中,我使用的是 SDK 发布的以下版本:
- Java:OpenJDK 11 的 11.0.12-open 版本
- Groovy:3.0.8 版本
使用色轮
在开始编码之前,请查看真正的色轮。如果您打开 GIMP(GNU 图像处理程序) 并查看屏幕的左上角部分,您会看到设置前景色和背景色的控件,在下图中用红色圈出

(Chris Hermansen,CC BY-SA 4.0)
如果您单击左上角的正方形(前景色),将打开一个如下所示的窗口

(Chris Hermansen,CC BY-SA 4.0)
如果它看起来不太像那样,请单击左上角行中从左数第四个按钮,它看起来像一个圆圈,里面刻着一个三角形。
三角形周围的环代表近乎连续的颜色范围。在上图中,从三角形指针(左侧中断圆圈的黑线)开始,颜色从蓝色渐变为青色、绿色、黄色、橙色、红色、品红色、紫色,然后回到蓝色。这就是色轮。如果您在该色轮上选择两个彼此相对的颜色,您将得到两种互补色。如果您在该色轮上均匀地选择 17 种颜色,您将得到 17 种尽可能不同的颜色。
确保您已选中窗口右上角的 HSV 按钮,然后查看标记为 H、S 和 V 的滑块。这些分别是色调 (hue)、饱和度 (saturation) 和明度 (value)。在选择对比色时,色调是重要的参数。
它的值从零到 360 度;在上图中,它是 192.9 度。
您可以使用此色轮手动计算另一种颜色的互补色——只需将 180 添加到您的颜色值,即可得到 372.9。接下来,减去 360,剩下 17.9 度。在 H 框中输入 17.9,替换 192.9,瞧,您就得到了它的互补色

(Chris Hermansen,CC BY-SA 4.0)
如果您检查标记为 HTML notation 的文本框,您会看到您开始使用的颜色是 #0080a3,其互补色是 #a33100。查看标记为 Current 和 Old 的字段,以查看两种颜色如何互补。
维基百科上有一篇非常出色且详细的文章,解释了 HSL(色调、饱和度和亮度)和 HSV(色调、饱和度和明度)颜色模型,以及如何在它们与我们大多数人熟悉的 RGB 标准之间进行转换。
我将在 Groovy 中自动化此过程。因为您可能希望以各种方式使用它,所以创建一个 Color 类,该类提供构造函数来创建 Color 实例,然后提供几种方法来查询 HSV 和 RGB 中实例的颜色。
这是 Color 类,后面附有解释
1 /**
2 * This class based on the color transformation calculations
3 * in https://en.wikipedia.org/wiki/HSL_and_HSV
4 *
5 * Once an instance of Color is created, it can be transformed
6 * between RGB triplets and HSV triplets and converted to and
7 * from hex codes.
8 */
9 public class Color {
10 /**
11 * May as well keep the color as both RGB and HSL triplets
12 * Keep each component as double to avoid as many rounding
13 * errors as possible.
14 */
15 private final Map rgb // keys 'r','g','b'; values 0-1,0-1,0-1 double
16 private final Map hsv // keys 'h','s','v'; values 0-360,0-1,0-1 double
17 /**
18 * If constructor provided a single int, treat it as a 24-bit RGB representation
19 * Throw exception if not a reasonable unsigned 24 bit value
20 */
21 public Color(int color) {
22 if (color < 0 || color > 0xffffff) {
23 throw new IllegalArgumentException('color value must be between 0x000000 and 0xffffff')
24 } else {
25 this.rgb = [r: ((color & 0xff0000) >> 16) / 255d, g: ((color & 0x00ff00) >> 8) / 255d, b: (color & 0x0000ff) / 255d]
26 this.hsv = rgb2hsv(this.rgb)
27 }
28 }
29 /**
30 * If constructor provided a Map, treat it as:
31 * - RGB if map keys are 'r','g','b'
32 * - Integer and in range 0-255 ⇒ scale
33 * - Double and in range 0-1 ⇒ use as is
34 * - HSV if map keys are 'h','s','v'
35 * - Integer and in range 0-360,0-100,0-100 ⇒ scale
36 * - Double and in range 0-360,0-1,0-1 ⇒ use as is
37 * Throw exception if not according to above
38 */
39 public Color(Map triplet) {
40 def keySet = triplet.keySet()
41 def types = triplet.values().collect { it.class }
42 if (keySet == ['r','g','b'] as Set) {
43 def minV = triplet.min { it.value }.value
44 def maxV = triplet.max { it.value }.value
45 if (types == [Integer,Integer,Integer] && 0 <= minV && maxV <= 255) {
46 this.rgb = [r: triplet.r / 255d, g: triplet.g / 255d, b: triplet.b / 255d]
47 this.hsv = rgb2hsv(this.rgb)
48 } else if (types == [Double,Double,Double] && 0d <= minV && maxV <= 1d) {
49 this.rgb = triplet
50 this.hsv = rgb2hsv(this.rgb)
51 } else {
52 throw new IllegalArgumentException('rgb triplet must have integer values between (0,0,0) and (255,255,255) or double values between (0,0,0) and (1,1,1)')
53 }
54 } else if (keySet == ['h','s','v'] as Set) {
55 if (types == [Integer,Integer,Integer] && 0 <= triplet.h && triplet.h <= 360
56 && 0 <= triplet.s && triplet.s <= 100 && 0 <= triplet.v && triplet.v <= 100) {
57 this.hsv = [h: triplet.h as Double, s: triplet.s / 100d, v: triplet.v / 100d]
58 this.rgb = hsv2rgb(this.hsv)
59 } else if (types == [Double,Double,Double] && 0d <= triplet.h && triplet.h <= 360d
60 && 0d <= triplet.s && triplet.s <= 1d && 0d <= triplet.v && triplet.v <= 1d) {
61 this.hsv = triplet
62 this.rgb = hsv2rgb(this.hsv)
63 } else {
64 throw new IllegalArgumentException('hsv triplet must have integer values between (0,0,0) and (360,100,100) or double values between (0,0,0) and (360,1,1)')
65 }
66 } else {
67 throw new IllegalArgumentException('triplet must be a map with keys r,g,b or h,s,v')
68 }
69 }
70 /**
71 * Get the color representation as a 24 bit integer which can be
72 * rendered in hex in the familiar HTML form.
73 */
74 public int getHex() {
75 (Math.round(this.rgb.r * 255d) << 16) +
76 (Math.round(this.rgb.g * 255d) << 8) +
77 Math.round(this.rgb.b * 255d)
78 }
79 /**
80 * Get the color representation as a map with keys r,g,b
81 * and the corresponding double values in the range 0-1
82 */
83 public Map getRgb() {
84 this.rgb
85 }
86 /**
87 * Get the color representation as a map with keys r,g,b
88 * and the corresponding int values in the range 0-255
89 */
90 public Map getRgbI() {
91 this.rgb.collectEntries {k, v -> [(k): Math.round(v*255d)]}
92 }
93 /**
94 * Get the color representation as a map with keys h,s,v
95 * and the corresponding double values in the ranges 0-360,0-1,0-1
96 */
97 public Map getHsv() {
98 this.hsv
99 }
100 /**
101 * Get the color representation as a map with keys h,s,v
102 * and the corresponding int values in the ranges 0-360,0-100,0-100
103 */
104 public Map getHsvI() {
105 [h: Math.round(this.hsv.h), s: Math.round(this.hsv.s*100d), v: Math.round(this.hsv.v*100d)]
106 }
107 /**
108 * Internal routine to convert an RGB triple to an HSV triple
109 * Follows the Wikipedia section https://en.wikipedia.org/wiki/HSL_and_HSV#Hue_and_chroma
110 * (almost) - note that the algorithm given there does not adjust H for G < B
111 */
112 private static def rgb2hsv(Map rgbTriplet) {
113 def max = rgbTriplet.max { it.value }
114 def min = rgbTriplet.min { it.value }
115 double c = max.value - min.value
116 if (c) {
117 double h
118 switch (max.key) {
119 case 'r': h = ((60d * (rgbTriplet.g - rgbTriplet.b) / c) + 360d) % 360d; break
120 case 'g': h = ((60d * (rgbTriplet.b - rgbTriplet.r) / c) + 120d) % 360d; break
121 case 'b': h = ((60d * (rgbTriplet.r - rgbTriplet.g) / c) + 240d) % 360d; break
122 }
123 double v = max.value // hexcone model
124 double s = max.value ? c / max.value : 0d
125 [h: h, s: s, v: v]
126 } else {
127 [h: 0d, s: 0d, v: 0d]
128 }
129 }
130 /**
131 * Internal routine to convert an HSV triple to an RGB triple
132 * Follows the Wikipedia section https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
133 */
134 private static def hsv2rgb(Map hsvTriplet) {
135 double c = hsvTriplet.v * hsvTriplet.s
136 double hp = hsvTriplet.h / 60d
137 double x = c * (1d - Math.abs(hp % 2d - 1d))
138 double m = hsvTriplet.v - c
139 if (hp < 1d) [r: c + m, g: x + m, b: 0d + m]
140 else if (hp < 2d) [r: x + m, g: c + m, b: 0d + m]
141 else if (hp < 3d) [r: 0d + m, g: c + m, b: x + m]
142 else if (hp < 4d) [r: 0d + m, g: x + m, b: c + m]
143 else if (hp < 5d) [r: x + m, g: 0d + m, b: c + m]
144 else if (hp < 6d) [r: c + m, g: 0d + m, b: x + m]
145 }
146 }
Color 类定义(从第 9 行开始到第 146 行结束)看起来很像 Java 类定义(乍一看,无论如何),它们会做同样的事情。但这是 Groovy,所以您在开头没有任何导入,只有注释。此外,细节说明了一些更多的 Groovyness。
第 15 行创建了私有 final 变量 rgb,其中包含提供给类构造函数的颜色值。您将把此值保留为 Map,键为 r
、g
和 b
以访问 RGB 值。将这些值保留为 0 到 1 之间的双精度值,以便 0 表示十六进制值 #00 或整数值 0,而 1 表示十六进制值 #ff 或整数值 255。使用双精度值可以避免在类内部转换时累积舍入误差。
类似地,第 16 行创建了私有 final 变量 hsv,其中包含相同的颜色值,但采用 HSV 格式——也是一个 Map,但键为 h
、s
和 v
以访问 HSV 值,这些值将保留为 0 到 360(色调)和 0 到 1(饱和度和明度)之间的双精度值。
第 21-28 行定义了一个 Color 构造函数,以便在传入 int 参数时调用。例如,您可以将其用作
def blue = new Color(0x0000ff)
- 在第 22-23 行,检查以确保传递给构造函数的参数在 24 位整数 RGB 构造函数的允许范围内,如果不在,则抛出异常。
- 在第 25 行,将私有变量 rgb 初始化为所需的 RGB Map,使用位移并将每个值除以双精度值 255,以将数字缩放到 0 到 1 之间。
- 在第 26 行,将 RGB 三元组转换为 HSV 并将其分配给私有变量 hsv。
第 39-69 行定义了另一个 Color 构造函数,以便在传入 RGB 或 HSV 三元组作为 Map 时调用。您可以将其用作
def green = new Color([r: 0, g: 255, b: 0])
或
def cyan = new Color([h: 180, s: 100, v: 100])
或者类似地,使用缩放到 0 到 1 之间的双精度值(而不是 RGB 情况下的 0 到 255 之间的整数,以及色调、饱和度和明度分别为 0 到 360、0 到 1 和 0 到 1)。
此构造函数看起来很复杂,在某种程度上,它确实如此。它检查 map 参数的 keySet() 以确定它表示 RGB 还是 HSV 元组。它检查传入的值的类,以确定这些值是否应解释为整数或双精度值,因此,它们是否缩放到 0-1(或色调为 0-360)。
无法使用此检查分类的参数被认为是错误的,并且会抛出异常。
值得注意的是 Groovy 提供的便捷简化
def types = triplet.values().collect { it.class }
这使用了 map 上的 values() 方法来获取作为 List 的值,然后使用该 List 上的 collect() 方法来获取每个值的类,以便稍后可以针对 [Integer,Integer,Integer] 或 [Double,Double,Double] 进行检查,以确保参数符合预期。
这是 Groovy 提供的另一个有用的简化
def minV = triplet.min { it.value }.value
min() 方法在 Map 上定义;它迭代 Map 并返回 MapEntry——一个 (键,值) 对——具有遇到的最小值。末尾的 .value 从该 MapEntry 中选择值字段,这提供了一些稍后可以检查的内容,以确定是否需要标准化值。
两者都依赖于 Groovy 闭包,类似于 Java lambda——一种在调用处定义的匿名过程。例如,collect() 接受单个 Closure 参数,并将其传递给遇到的每个 MapEntry,这在闭包主体中称为参数。此外,Groovy Collection 接口的各种实现(包括此处的 Map)定义了 collect() 和 min() 方法,这些方法迭代 Collection 的元素并调用 Closure 参数。最后,Groovy 的语法支持这些各种功能的紧凑且低仪式感的调用。
第 70-106 行定义了五个“getter”,它们以五种格式之一返回用于创建实例的颜色
- getHex() 返回与 24 位 HTML RGB 颜色对应的 int。
- getRgb() 返回一个 Map,键为
r
、g
、b
,对应的双精度值范围为 0-1。 - getRgbI() 返回一个 Map,键为
r
、g
、b
,对应的整数值范围为 0-255。 - getHsv() 返回一个 Map,键为
h
、s
、v
,对应的双精度值范围分别为 0-360、0-1 和 0-1。 - getHsvI() 返回一个 Map,键为
h
、s
、v
,对应的整数值范围分别为 0-360、0-100 和 0-100。
第 112-129 行定义了一个静态私有(内部)方法 rgb2hsv(),该方法将 RGB 三元组转换为 HSV 三元组。这遵循了维基百科文章 关于色调和色度的章节 中描述的算法,但那里的算法在绿色值小于蓝色值时会产生负色调值,因此该版本略有修改。除了使用 max() 和 min() Map 方法以及声明式地返回 Map 实例而没有 return 语句之外,此代码并没有特别 Groovy 的地方。
这两个 getter 方法使用此方法以正确的形式返回 Color 实例值。由于它不引用任何实例字段,因此它是静态的。
类似地,第 134-145 行定义了另一个私有(内部)方法 hsv2rgb(),该方法将 HSV 三元组转换为 RGB 三元组,遵循维基百科文章 关于 HSV 到 RGB 转换的章节 中描述的算法。构造函数使用此方法将 HSV 三元组参数转换为 RGB 三元组。由于它不引用任何实例字段,因此它是静态的。
就是这样。以下是如何使用此类的一个示例
1 def favBlue = new Color(0x0080a3)
2 def favBlueRgb = favBlue.rgb
3 def favBlueHsv = favBlue.hsv
4 println "favBlue hex = ${sprintf('0x%06x',favBlue.hex)}"
5 println "favBlue rgbt = ${favBlue.rgb}"
6 println "favBlue hsvt = ${favBlue.hsv}"
7 int spokeCount = 8
8 double dd = 360d / spokeCount
9 double d = favBlue.hsv.h
10 for (int spoke = 0; spoke < spokeCount; spoke++) {
11 def color = new Color(h: d, s: favBlue.hsv.s, v: favBlue.hsv.v)
12 println "spoke $spoke $d° hsv ${color.hsv}"
13 println " hex ${sprintf('0x%06x',color.hex)} hsvI ${color.hsvI} rgbI ${color.rgbI}"
14 d = (d + dd) % 360d
15 }
作为我的起始值,我选择了 opensource.com 标题 #0080a3 中较浅的蓝色,并且我正在打印一组另外七种颜色,这些颜色与原始蓝色给出了最大的分离度。我将围绕色轮的每个位置称为一个辐条,并在变量 d 中计算其位置(以度为单位),变量 d 在每次循环中按每个辐条之间的度数 dd 递增。
只要 Color.groovy
和此测试脚本在同一目录中,您就可以按如下方式编译和运行它们
$ groovy test1Color.groovy
favBlue hex = 0x0080a3
favBlue rgbt = [r:0.0, g:0.5019607843137255, b:0.6392156862745098]
favBlue hsvt = [h:192.88343558282207, s:1.0, v:0.6392156862745098]
spoke 0 192.88343558282207° hsv [h:192.88343558282207, s:1.0, v:0.6392156862745098]
hex 0x0080a3 hsvI [h:193, s:100, v:64] rgbI [r:0, g:128, b:163]
spoke 1 237.88343558282207° hsv [h:237.88343558282207, s:1.0, v:0.6392156862745098]
hex 0x0006a3 hsvI [h:238, s:100, v:64] rgbI [r:0, g:6, b:163]
spoke 2 282.8834355828221° hsv [h:282.8834355828221, s:1.0, v:0.6392156862745098]
hex 0x7500a3 hsvI [h:283, s:100, v:64] rgbI [r:117, g:0, b:163]
spoke 3 327.8834355828221° hsv [h:327.8834355828221, s:1.0, v:0.6392156862745098]
hex 0xa30057 hsvI [h:328, s:100, v:64] rgbI [r:163, g:0, b:87]
spoke 4 12.883435582822074° hsv [h:12.883435582822074, s:1.0, v:0.6392156862745098]
hex 0xa32300 hsvI [h:13, s:100, v:64] rgbI [r:163, g:35, b:0]
spoke 5 57.883435582822074° hsv [h:57.883435582822074, s:1.0, v:0.6392156862745098]
hex 0xa39d00 hsvI [h:58, s:100, v:64] rgbI [r:163, g:157, b:0]
spoke 6 102.88343558282207° hsv [h:102.88343558282207, s:1.0, v:0.6392156862745098]
hex 0x2fa300 hsvI [h:103, s:100, v:64] rgbI [r:47, g:163, b:0]
spoke 7 147.88343558282207° hsv [h:147.88343558282207, s:1.0, v:0.6392156862745098]
hex 0x00a34c hsvI [h:148, s:100, v:64] rgbI [r:0, g:163, b:76]
您可以看到辐条的度数位置反映在 HSV 三元组中。我还打印了十六进制 RGB 值以及 RGB 和 HSV 三元组的整数版本。
我本可以在 Java 中构建它。如果我这样做,我可能会创建单独的 RgbTriple 和 HsvTriple 辅助类,因为 Java 不提供 Map 的声明式语法。这将使查找最小值和最大值更加冗长。因此,像往常一样,Java 会更冗长,但不会提高可读性。不过,会有三个构造函数,这可能是一个更直接的命题。
我可以像对饱和度和明度一样对色调使用 0-1,但不知何故我更喜欢 0-360。
最后,我可以添加——并且我可能仍然会在某一天这样做——其他转换,例如 HSL。
总结
色轮在许多情况下都很有用,用 Groovy 构建一个色轮是学习色轮如何工作以及 Groovy 的“grooviness”的绝佳练习。慢慢来;上面的代码很长。但是,您可以构建自己的实用颜色计算器,并在过程中学到很多东西。
Groovy 资源
Apache Groovy 语言站点提供了 关于使用 Collection(特别是 Map 类)的良好教程级概述。本文档非常简洁易懂,至少部分原因是它记录的工具本身被设计为简洁易用!
评论已关闭。