You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

10 | 图形系统如何表示颜色?

你好,我是月影。从这一节课开始,我们进入一个全新的模块,开始学习视觉基础。

在可视化领域中图形的形状和颜色信息非常重要它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色虽然在前面的课程中我们也使用片元着色器给图形设置了不同的颜色可这只是颜色的基本用法Web图形系统对颜色的支持是非常强大的。

所以这一节课我们就来系统地学习一下Web图形系统中表示颜色的基本方法。我会讲四种基本的颜色表示法分别是RGB和RGBA颜色表示法、HSL和HSV颜色表示法、CIE Lab和CIE Lch颜色表示法以及Cubehelix色盘。

不过,因为颜色表示实际上是一门非常复杂的学问,与我们自己的视觉感知以及心理学都有很大的关系,所以这节课我只会重点讲解它们的应用,不会去细说其中复杂的算法实现和规则细节。但我也会在课后给出一些拓展阅读的链接,如果你有兴趣,可以利用它们深入来学。

RGB和RGBA颜色

作为前端工程师你一定对RGB和RGBA颜色比较熟悉。在Web开发中我们首选的颜色表示法就是RGB和RGBA。那我们就先来说说它的应用。

1. RGB和RGBA的颜色表示法

我们在CSS样式中看到的形式如#RRGGBB的颜色代码就是RGB颜色的十六进制表示法其中RR、GG、BB分别是两位十六进制数字表示红、绿、蓝三色通道的色阶。色阶可以表示某个通道的强弱。

因为RGB(A)颜色用两位十六进制数来表示每一个通道的色阶所以每个通道一共有256阶取值是0到255。RGB的三个通道色阶的组合理论上一共能表示224 也就是一共16777216种不同的颜色。因此RGB颜色是将人眼可见的颜色表示为红、绿、蓝三原色不同色阶的混合。我们可以用一个三维立方体把RGB能表示的所有颜色形象地描述出来。效果如下图

那RGB能表示人眼所能见到的所有颜色吗事实上RGB色值只能表示这其中的一个区域。如下图所示灰色区域是人眼所能见到的全部颜色中间的三角形是RGB能表示的所有颜色你可以明显地看出它们的对比。

尽管RGB色值不能表示人眼可见的全部颜色但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。

在浏览器中CSS一般有两种表示RGB颜色值的方式一种是我们前面说的#RRGGBB表示方式另一种是直接用rgb(red, green, blue)表示颜色这里的“red、green、blue”是十进制数值。RGB颜色值的表示方式你应该比较熟悉我就不多说了。

理解了RGB之后我们就很容易理解RGBA了。它其实就是在RGB的基础上增加了一个Alpha通道也就是透明度。一些新版本的浏览器可以用#RRGGBBAA的形式来表示RGBA色值但是较早期的浏览器只支持rgba(red, green, blue, alpha)这种形式来表示色值注意这里的alpha是一个从0到1的数。所以在实际使用的时候我们要注意这一点。

WebGL的shader默认支持RGBA。因为在WebGL的shader中我们是使用一个四维向量来表示颜色的向量的r、g、b、a分量分别表示红色、绿色、蓝色和alpha通道。不过和CSS的颜色表示稍有不同的是WebGL采用归一化的浮点数值也就是说WebGL的颜色分量r、g、b、a的数值都是0到1之间的浮点数。

2. RGB颜色表示法的局限性

RGB和RGBA的颜色表示法非常简单但使用起来也有局限性因为RGB和RGBA本质上其实非常相似只不过后者比前者多了一个透明度通道。方便起见我们后面就用RGB来代表RGB和RGBA了

因为对一个RGB颜色来说我们只能大致直观地判断出它偏向于红色、绿色还是蓝色或者在颜色立方体的大致位置。所以在对比两个RGB颜色的时候我们只能通过对比它们在RGB立方体中的相对距离来判断它们的颜色差异。除此之外我们几乎就得不到其他任何有用的信息了。

也就是说,当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明。因此RGB颜色对用户其实并不友好。

这么说可能还是比较抽象我们来看一个简单的例子。这里我们在画布上显示3组颜色不同的圆每组各5个用来表示重要程度不同的信息。现在我们给这些圆以随机的RGB颜色代码如下

import {Vec3} from '../common/lib/math/vec3.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function randomRGB() {
  return new Vec3(
    0.5 * Math.random(),
    0.5 * Math.random(),
    0.5 * Math.random(),
  );
}

ctx.translate(256, 256);
ctx.scale(1, -1);

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();
  }
}

通过执行上面的代码我们生成随机的三维向量然后将它转成RGB颜色。为了保证对比我们在每一组的5个圆中依次用0.5、0.75、1.0、1.25和1.5的比率乘上我们随机生成的RGB数值。这样一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗越右边的越亮。得到的效果如下

但是这么做有两个缺点首先因为这个例子里的RGB颜色是随机产生的所以行与行之间的颜色差别可能很大也可能很小我们无法保证具体的颜色差别大小其次因为无法控制随机生成的颜色本身的亮度所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如下图中另一组随机生成的圆除了第一行外后面两行的颜色都很暗区分度太差。

因此,在需要动态构建视觉颜色效果的时候我们很少直接选用RGB色值而是使用其他的颜色表示形式。这其中比较常用的就是HSL和HSV颜色表示形式。

HSL和HSV颜色

与RGB颜色以色阶表示颜色不同HSL和HSV用色相Hue、饱和度Saturation和亮度Lightness或明度Value来表示颜色。其中Hue是角度取值范围是0到360度饱和度和亮度/明度的值都是从0到100%。

虽然HSL和HSV在表示方法上有一些区别但它们能达到的效果比较接近。所以就目前来说我们并不需要深入理解它们之间的区别只要学会HSL和HSV通用的颜色表示方法就可以了。

1. HSL和HSV的颜色表示方法

HSL和HSV是怎么表示颜色的呢实际上我们可以把HSL和HSV颜色理解为是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上所以它的色值和RGB色值是一一对应的。

从上图中你可以发现它们之间色值的互转算法比较复杂。不过好在CSS和Canvas2D都可以直接支持HSL颜色只有WebGL需要做转换。所以如果你有兴趣深入了解这个转换算法可以去看一下我课后给出的推荐阅读。那在这里你只需要记住我下面给出的这一段RGB和HSV的转换代码就可以了后续课程中我们会用到它。

vec3 rgb2hsv(vec3 c){
  vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
  vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
  vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
  float d = q.x - min(q.w, q.y);
  float e = 1.0e-10;
  return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

vec3 hsv2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

记住了转换代码之后。下面我们直接用HSL颜色改写前面绘制三排圆的例子。这里我们只要把代码稍微做一些调整。

function randomColor() {
  return new Vec3(
    0.5 * Math.random(), // 初始色相随机取0~0.5之间的值
    0.7,  // 初始饱和度0.7
    0.45,  // 初始亮度0.45
  );
}

ctx.translate(256, 256);
ctx.scale(1, -1);

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();
  }
}

如上面代码所示我们生成随机的HSL颜色主要是随机色相H然后我们将H值的角度拉开就能保证三组圆彼此之间的颜色差异比较大。

接着我们增大每一列圆的饱和度和亮度这样每一行圆的亮度和饱和度就都不同了。但要注意的是我们要同时增大亮度和饱和度。因为根据HSL的规则亮度越高颜色越接近白色只有同时提升饱和度才能确保圆的颜色不会太浅。

2. HSL和HSV的局限性

不过,从上面的例子中你也可以看出来,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是为什么呢?这里我先卖个关子,我们先来做一个简单的实验。

for(let i = 0; i < 20; i++) {
  ctx.fillStyle = `hsl(${Math.floor(i * 15)}, 50%, 50%)`;
  ctx.beginPath();
  ctx.arc((i - 10) * 20, 60, 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, -60, 10, 0, Math.PI * 2);
  ctx.fill();
}

如上面代码所示我们绘制两排不同的圆让第一排每个圆的色相间隔都是15再让第二排圆的颜色在色相60和210附近两两交错。然后我们让这两排圆的饱和度和亮度都是50%,最终生成的效果如下:

先看第一排圆你会发现虽然它们的色相相差都是15但是相互之间颜色变化并不是均匀的尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆虽然这些圆的亮度都是50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。

因此HSL依然不是最完美的颜色方法我们还需要建立一套针对人类知觉的标准这个标准在描述颜色的时候要尽可能地满足以下2个原则

  1. 人眼看到的色差 = 颜色向量间的欧氏距离
  2. 相同的亮度,能让人感觉亮度相同

于是一个针对人类感觉的颜色描述方式就产生了它就是CIE Lab。

CIE Lab和CIE Lch颜色

CIE Lab颜色空间简称Lab它其实就是一种符合人类感觉的色彩空间它用L表示亮度a和b表示颜色对立度。RGB值也可以Lab转换但是转换规则比较复杂你可以通过wikipedia.org来进一步了解它的基本原理。

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

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

而且一些JavaScript库也已经可以直接处理Lab颜色空间了d3-color。下面我们通过一个代码例子来详细讲讲d3.lab是怎么处理Lab颜色的。如下面代码所示我们使用d3.lab来定义Lab色彩。这个例子与HSL的例子一样也是显示两排圆形。这里我们让第一排相邻圆形之间的lab色值的欧氏空间距离相同第二排相邻圆形之间的亮度按5阶的方式递增。

/* global d3 */
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();
}

代码最终的运行效果如下:

你会发现在以CIELab方式呈现的色彩变化中我们设置的数值和人眼感知的一致性比较强。

而CIE Lch和CIE Lab的对应方式类似于RGB和HSL和HSV的对应方式也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系这里就不再多说了。CIE Lch和CIE Lab表示颜色的技术还比较新所以目前我们也不会接触很多但是因为它能呈现的色彩更贴近人眼的感知所以我相信它会发展得很快。作为技术人这些新技术我们也要持续关注。

Cubehelix色盘

最后我们再来说一种特殊的颜色表示法Cubehelix色盘立方螺旋色盘。简单来说它的原理就是在RGB的立方中构建一段螺旋线让色相随着亮度增加螺旋变换。如下图所示

我们还是直接来看它的应用。接下来我会直接用NPM上的cubehelix模块写一个颜色随着长度变化的柱状图你可以通过它来看看Cubehelix是怎么应用的。效果如下

它的实现代码也非常简单,我来简单说一下思路。

首先我们直接使用cubehelix函数创建一个color映射。cubehelix函数是一个高阶函数它的返回值是一个色盘映射函数。这个返回函数的参数范围是0到1当它从小到大依次改变的时候不仅颜色会依次改变亮度也会依次增强。然后我们用正弦函数来模拟数据的周期性变化通过color§获取当前的颜色值再把颜色值赋给ctx.fillStyle颜色就能显示出来了。最后我们用rect将柱状图画出来用requestAnimationFrame实现动画就可以了 。

import {cubehelix} from 'cubehelix';

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(0, 256);
ctx.scale(1, -1);

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

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

update(0);

到这里我们关于颜色表示的讨论就告一段落了。这4种颜色方式的具体应用你应该已经掌握了那我再来说说在实际工作中它们的具体使用场景这样你就能记得更深刻了。

在可视化应用里一般有两种使用颜色的方式第一种整个项目的UI配色全部由UI设计师设计好提供给可视化工程师使用。那在这种情况下设计师设计的颜色是多少就是多少开发者使用任何格式的颜色都行。第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择而一般是由设计师定下视觉基调和一些主色开发者根据主色和数据来生成对应的颜色。

在一般的图表呈现项目中第一种方式使用较多。而在一些数据比较复杂的项目中我们经常会使用第二种方式。尤其是当我们希望连续变化的数据能够呈现连续的颜色变换时设计师就很难用预先指定的有限的颜色来表达了。这时候我们就需要使用其他的方式比如HLS、CIELab或者Cubehelix色盘我们会把它们结合数据变量来动态生成颜色值。

要点总结

这一节课我们系统地学习了Web图形系统表示颜色的方法。它们可以分为2大类分别是RGB、HSL和HSV、CIELab和CIELch等颜色空间的表示方法以及Cubehelix色盘的表示方法。

首先RGB用三原色的色阶来表示颜色是最基础的颜色表示法但是它对用户不够友好。而HSL和HSV是用色相、饱和度、亮度明度来表示颜色对开发者比较友好但是它的数值变换与人眼感知并不完全相符。

CIELab和CIELch与Cubehelix色盘这两种颜色表示法还比较新在实际工作中使用得不是很多。其中CIELab和CIELch是与人眼感知相符的色彩空间表示法已经被纳入css-color level4规范中。虽然还没有被浏览器支持但是一些如d3-color这样的JavaScript库可以直接处理Lab颜色空间。而如果我们要呈现颜色随数据动态改变的效果那Cubehelix色盘就是一种非常更合适的选择了。

最后,我还想再啰嗦几句。在可视化中,我们会使用图形的大小、高低、宽窄、颜色和形状这些常见信息来反映数据。一般来说,我们会使用一种叫做二维强化的技巧,来叠加两个维度的信息,从而加强可视化的视觉呈现效果。

比如,柱状图的高低表示了数据的多少,但是如果这个数据非常重要,那么我们在给柱状图设置不同高低的同时,再加上颜色的变化,就能让这个柱状图更具视觉冲击力。这也是我们必须要学会熟练运用颜色的原因。

所以,颜色的使用在可视化呈现中非常重要,在之后的课程中,我们还会继续深入探讨颜色的用法。

小试牛刀

我在课程中给出了hsv和rgb互转的glsl代码。你能尝试用WebGL画两个圆让它们的角度对应具体的HUE色相值让其中一个圆的半径对应饱和度S另一个圆的半径对应明度V将HSV色盘绘制出来吗

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!


源码

本节课示例代码完整版

推荐阅读

颜色也是可视化非常重要的内容,所以这节课的知识点比较多,参考资料也很多。如果你有兴趣深入研究,我建议你一定要认真看看我给的这些资料。

1
2
3
4
5
6
7
8
9