使用我的 Groovy 色轮计算器

色轮在许多情况下都很有用,用 Groovy 构建一个色轮是学习色轮如何工作以及 Groovy 的“grooviness”的绝佳练习。
2 位读者喜欢这篇文章。
tie dye fabric

Lisa Padilla。由 Opensource.com 修改。CC BY-SA 4.0

我时不时地发现自己需要计算互补色。例如,我可能正在 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 图像处理程序) 并查看屏幕的左上角部分,您会看到设置前景色和背景色的控件,在下图中用红色圈出

Controls to set foreground and background colors

(Chris Hermansen,CC BY-SA 4.0)

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

Set foreground color

(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,瞧,您就得到了它的互补色

Change foreground color

(Chris Hermansen,CC BY-SA 4.0)

如果您检查标记为 HTML notation 的文本框,您会看到您开始使用的颜色是 #0080a3,其互补色是 #a33100。查看标记为 CurrentOld 的字段,以查看两种颜色如何互补。

维基百科上有一篇非常出色且详细的文章,解释了 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,键为 rgb 以访问 RGB 值。将这些值保留为 0 到 1 之间的双精度值,以便 0 表示十六进制值 #00 或整数值 0,而 1 表示十六进制值 #ff 或整数值 255。使用双精度值可以避免在类内部转换时累积舍入误差。

类似地,第 16 行创建了私有 final 变量 hsv,其中包含相同的颜色值,但采用 HSV 格式——也是一个 Map,但键为 hsv 以访问 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”,它们以五种格式之一返回用于创建实例的颜色

  1. getHex() 返回与 24 位 HTML RGB 颜色对应的 int。
  2. getRgb() 返回一个 Map,键为 rgb,对应的双精度值范围为 0-1。
  3. getRgbI() 返回一个 Map,键为 rgb,对应的整数值范围为 0-255。
  4. getHsv() 返回一个 Map,键为 hsv,对应的双精度值范围分别为 0-360、0-1 和 0-1。
  5. getHsvI() 返回一个 Map,键为 hsv,对应的整数值范围分别为 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 中构建它。如果我这样做,我可能会创建单独的 RgbTripleHsvTriple 辅助类,因为 Java 不提供 Map 的声明式语法。这将使查找最小值和最大值更加冗长。因此,像往常一样,Java 会更冗长,但不会提高可读性。不过,会有三个构造函数,这可能是一个更直接的命题。

我可以像对饱和度和明度一样对色调使用 0-1,但不知何故我更喜欢 0-360。

最后,我可以添加——并且我可能仍然会在某一天这样做——其他转换,例如 HSL。

总结

色轮在许多情况下都很有用,用 Groovy 构建一个色轮是学习色轮如何工作以及 Groovy 的“grooviness”的绝佳练习。慢慢来;上面的代码很长。但是,您可以构建自己的实用颜色计算器,并在过程中学到很多东西。


Groovy 资源

Apache Groovy 语言站点提供了 关于使用 Collection(特别是 Map 类)的良好教程级概述。本文档非常简洁易懂,至少部分原因是它记录的工具本身被设计为简洁易用!

标签
Chris Hermansen portrait Temuco Chile
自从 1978 年从不列颠哥伦比亚大学毕业以来,我几乎一直离不开计算机,从 2005 年开始成为全职 Linux 用户,从 1986 年到 2005 年成为全职 Solaris 和 SunOS 用户,在此之前是 UNIX System V 用户。

评论已关闭。

© . All rights reserved.