可视化学习:图形系统中的颜色表示

发布时间 2023-12-28 12:53:13作者: beckyye

引言

说到颜色,前端的小伙伴们一定都不陌生,比如字体颜色、背景色等等,颜色是构建视觉效果的重要部分,所以也必然是可视化的关键部分,当学习到可视化中有关于颜色表示的这一部分时,我也想起了我曾经玩过的一个游戏,叫做Blendoku,这个名字和数独的Sudoku有些类似,考验的是玩家对颜色的敏锐度,下面是其中一个关卡的截图,可以明显看出,这个截图中有一个颜色渐变的趋势。

blendoku

色彩对人的视觉感知以及情绪心理都存在不少的影响,所以了解颜色表示对可视化非常重要。那么图形系统中都有哪些颜色表示方式呢?

RGB/RGBA

我想很多人应该和我一样,对于RGB和RGBA的色值形式是最熟悉的,对我来说,其他的颜色表示方式用的很少,了解的也很少,HSL还略有所耳闻,但是对于CIE Lab、Cubehelix这些,在学习可视化前,我甚至都没怎么听说过,当我们拿到一份设计稿试图去还原页面时,首选的色值基本都是RGB/RGBA的表示形式。它使用起来非常简单,也很好理解,RGB三个字母分别代表了Red、Green、Blue,也就是红、绿、蓝三个颜色通道的色阶,色阶代表了某个通道的强弱。

RGB有两种写法,一种是十六进制的形式,另一种是rgb/rgba函数的形式。在十六进制形式中,使用两位数来表示某一通道的色阶,最小能表示的值是00,最大能表示的值为FF,转换为十进制就是0到255,因此每个通道分别有256阶。

我们可以用一个三维立方体,把RGB能表示的所有颜色描述出来。就如下图所示:

rgb1

根据此图显而易见,RGB色值并不能表示人眼可见的所有颜色;但就平常的使用而言,也足够丰富了,大多数设备,比如一般的显示器、彩色打印机、扫描仪等等,都支持RGB的颜色表示。

RGBA则是在RGB的基础上增加了一个对应透明度的alpha通道。

对于一般的网页开发而言,RGB/RGBA的使用并没什么太大的问题,但是如果用于数据可视化方面的开发,就存在比较明显的短板。

比如需要根据数据生成一组对比明显的颜色,来进行图表的展示,但实际上从RGB的色值上,我们并不能得到关于两个颜色的实际差异,也就是说,两个色值之间的差值,只能反映出它们在RGB立方体中的相对距离。

比如下面这个例子:

我们在画布上生成3组颜色不同的圆,每组5个圆;颜色使用随机生成。

import { Vec3 } from 'https://unpkg.com/ogl';
// ...
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(1, -1); // 绕X轴翻转

for (let i = 0; i <  3; i ++) {
  const colorVector = randomRGB();
  for (let j = 0; j < 5; j ++) {
    const c = colorVector.clone().scale(0.5 + 0.25 * j);
    ctx.fillStyle = `rgb(${Math.floor(c[0] * 256)}, ${Math.floor(c[1] * 256)}, ${Math.floor(c[2] * 256)})`;
    ctx.beginPath();
    ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
    ctx.fill();
  }
}

function randomRGB() {
  return new Vec3(
      0.5 * Math.random(),
      0.5 * Math.random(),
      0.5 * Math.random(),
  )
}
  • 首先我们生成随机的三维向量colorVector,用于后续构建RGB颜色,0.5 * Math.random()使得每个分量的范围都是[0, 0.5)
  • 然后我们在每一组圆上,依次用0.5、0.75、1.0、1.25和1.5的比率乘以随机生成的三维向量,再通过乘以256,就得到了一个随机的RGB色值

这样,一组圆就能呈现不同的亮度;总体上,越左边越暗,越右边越亮。但我们能发现,这样子生成的随机RGB颜色存在两个缺点:

  1. 行与行之间的颜色差别可能很大,也可能很小
  2. 我们无法控制随机生成的颜色本身的亮度,一组圆的颜色可能都很亮或者都很暗,区分度差

总的来说,就是随机生成的RGB颜色彼此之间的区分度不能保证;因此,在需要动态构建颜色时,很少直接用RGB色值,而是使用其他的颜色表示形式;其中比较常用的就是HSL和HSV颜色表示形式。

HSL/HSV

HSL和HSV用色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。

其中,Hue是角度,取值范围是0到360度,饱和度和亮度/明度的取值都是从0到100%。

虽然HSL和HSV有一些区别,但实现的效果比较接近。

简单来说,我们可以把HSL和HSV理解为,是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和RGB色值是一一对应的。可以参考下图:

hsl1

它们之间互相转换的算法比较复杂。CSS和Canvas2D可以直接支持HSL颜色,只有WebGL中需要我们自己去转换,一般而言直接使用一些现有的转换代码就足够了,如果有对这个实现算法感兴趣的小伙伴,可以自己去深入研究一下。

现在我们用HSL颜色改写前面三排圆的例子,同样也是随机生成颜色:

import {Vec3} from 'https://unpkg.com/ogl';
// ...
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(1, -1); // 绕X轴翻转

const [h, s, l] = randomColor();
for (let i = 0; i <  3; i ++) {
  const p = (i * 0.25 + h) % 1;
  for (let j = 0; j < 5; j ++) {
    const d = j - 2;
    ctx.fillStyle = `hsl(${Math.floor(p * 360)}, ${Math.floor((0.15 * d + s) * 100)}%, ${Math.floor((0.12 * d + l) * 100)}%)`;
    ctx.beginPath();
    ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
    ctx.fill();
  }
}

function randomColor() {
  return new Vec3(
      0.5 * Math.random(), // 色相:0~0.5之间的值
      0.7, // 初始饱和度 0.7
      0.45, // 初始亮度 0.45
  )
}
  • 首先依旧是生成随机的三维向量,调用randomColor()方法,用于后面计算HSL颜色,第一个分量的取值范围是[0, 0.5),与色相Hue的计算有关,第二个分量0.7,与饱和度的生成有关,第三个分量0.45,与亮度的生成有关
  • 然后在每一组圆上,依次设置每个圆的饱和度为0.4、0.55、0.7、0.85和1.0,设置每个圆的亮度为0.21、0.33、0.45、0.57和0.69

以上代码中,我们主要生成了一个随机的值,用于表示色相,通过i * 0.25加上随机值,来将每一行色相的角度拉开,从而保证三组圆之间的色相差异;并且每一组圆之间通过不同的饱和度和亮度做出区分。

从效果上看,比生成的RGB随机颜色要好不少。但是多试几次,还是能发现,有些颜色差距还是没那么明显。这是因为受到人眼视觉感知的影响。

我们可以通过一个简单的实验来直观感受这种影响:

for (let i = 0; i < 20; i ++) {
  ctx.fillStyle = `hsl(${Math.floor(i * 15)}, 50%, 50%)`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 200, 10, 0, Math.PI * 2);
  ctx.fill();
}
for (let i = 0; i < 20; i ++) {
  ctx.fillStyle = `hsl(${Math.floor((i % 2 ? 60 : 210) + 3 * i)}, 50%, 50%`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 140, 10, 0, Math.PI * 2);
  ctx.fill();
}

以上代码绘制了两排圆,第一排每个圆之间的色相间隔都是15,饱和度和亮度都是50%;第二排圆的颜色,色相在60和210附近两两交互,饱和度和亮度也都是50%。

观察第一排圆可以明显发现,虽然相邻的圆之间色相相差都是15,但颜色过渡并不均匀,尤其几个绿色的圆视觉上颜色比较接近;而第二排圆,虽然饱和度和亮度都是一样的,但蓝色和紫色的圆明显不如绿色和黄色的圆亮眼。这是由于人眼对不同频率的光的敏感度不同所产生的结果。也就是说,虽然区分度够了,但是对于人眼感知HSL还是欠缺完美

因此我们还需要一套更接近人类知觉的颜色标准,它需要尽量满足2个原则:

第一,人眼看到的色差 = 颜色向量间的欧式距离,这样子计算出的颜色差值更能符合人眼视觉感知到的色差;

第二,相同亮度的两个颜色,能让人从视觉上也感觉亮度相同。

于是就诞生了CIE Lab。

CIE Lab和CIE Lch

CIE Lab颜色空间,简称Lab,是一种符合人类感觉的色彩空间,其中L表示亮度,a和b表示颜色对立度。

RGB色值也可以与Lab转换,但转换规则比较复杂。

比较欠缺的一点就是,目前还没有图形系统支持CIE Lab,但是css-color-level4规范已经给出了Lab颜色值的定义。

lab() = lab( [<percentage> | <number> | none]
      [ <percentage> | <number> | none]
      [ <percentage> | <number> | none]
      [ / [<alpha-value> | none] ]? )

尽管如此,一些走在前沿的探索者们已经开发出了可以直接处理Lab颜色空间的JavaScript库,比如d3-color。

以下的例子展示了d3.lab是如何处理Lab颜色的:

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(1, -1); // 绕X轴翻转

for (let i = 0; i < 20; i ++) {
  const c = d3.lab(30, i * 15 - 150, i * 15 - 150).rgb();
  ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
  ctx.fill();
}

for (let i = 0; i < 20; i ++) {
  const c = d3.lab(i * 5, 80, 80).rgb();
  ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
  ctx.fill();
}

上述代码中使用d3.lab来定义Lab色值。

第一排圆,相邻的色值,欧式空间距离相同;第二排圆,颜色的亮度按5阶的方式递增。

在这里d3.lab处理Lab颜色的方式,就是把Lab色值转换为rgb色值后,再提供给Canvas2D使用。

看得出来,与HSL对比,使用Lab生成的颜色,更接近人眼的感知。

而CIE Lch和CIE Lab的关系,也是类似于将坐标从立方体的直角坐标系变换为圆柱体的极坐标系。

目前CIE Lch和CIE Lab的颜色表示方式还比较新,应用的也不太多,但由于符合人眼感知,可以对其保持关注。

Cubehelix色盘

最后一块是Cubehelix色盘,它的原理是,在RGB的立方体中,构建一段螺旋线,让色相随着亮度增加螺旋变换。就如下图所示:

可以看出,非常适合用于实现颜色随数据动态改变的效果。比如下面这个例子:

import {cubehelix} from 'cubehelix';
// ...
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(1, -1); // 绕X轴翻转

const color = cubehelix(); // 色盘颜色映射函数
const T = 2000;

function update(t) {
  const p = 0.5 + 0.5 * Math.sin(t / T);
  console.log(p);
  ctx.clearRect(-256, -256, 512, 512);
  const {r, g, b} = color(p);
  ctx.fillStyle = `rgb(${255 * r}, ${255 * g}, ${255 * b})`;
  ctx.beginPath();
  ctx.rect(-236, -20, 480 * p, 40);
  ctx.fill();
  requestAnimationFrame(update);
}

update(0);

实现的效果如下:

可以看到颜色会随着时间的推延发生周期性的变化。

  • color是一个色盘映射函数,接收一个参数,参数值的范围为0到1。
  • 这里用正弦函数来模拟数据的周期性变化

总结

在前端开发中,颜色的使用随处可见,一般在开发过程中,有两种定义色值的方式。

第一种,是由UI设计师来指定全部配色,这也是普通前端开发中大多数的方式;

第二种,是根据数据来动态地生成颜色值,这在数据比较复杂的项目中比较常用。

对于第二种情况,颜色能在数据可视化中提供比较重要的信息,是值得我们重视的,而对于普通的前端开发,更好地掌握颜色的使用,也能为用户提供更加友好的交互。