視覺化學習:圖形系統中的顏色表示

beckyye發表於2023-12-28

引言

說到顏色,前端的小夥伴們一定都不陌生,比如字型顏色、背景色等等,顏色是構建視覺效果的重要部分,所以也必然是視覺化的關鍵部分,當學習到視覺化中有關於顏色表示的這一部分時,我也想起了我曾經玩過的一個遊戲,叫做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設計師來指定全部配色,這也是普通前端開發中大多數的方式;

第二種,是根據資料來動態地生成顏色值,這在資料比較複雜的專案中比較常用。

對於第二種情況,顏色能在資料視覺化中提供比較重要的資訊,是值得我們重視的,而對於普通的前端開發,更好地掌握顏色的使用,也能為使用者提供更加友好的互動。

相關文章