WS2812 HSB 色彩校正

我用 8266 造 PWM 風扇調速既簡單又實用用左好耐,之後又諗倒用粒 WS2812B LED 變色黎顯示速度快慢,發現 Hue 值嘅改變唔係線性,出現變色變得太多或者太小唔容易察覺嘅問題,於是要自己柬幾個色出黎用 Hue 個数寫成 array 去對表示 pwm 大細。我用嘅係 NeoPixelBus library,8266 嘅話最好用 RX 即係 GPIO3 就會自動用 DMA + I2C method。如果有好多粒要更快,因為 ESP32 都有 hardware timer,就算低階嘅 ESP32C3 就算同 8266 一樣係單核 160MHz,,同一段曲NeoPixelBus<NeoGrbFeature, NeoWs2812xMethod> ws2812(COUNT, PIN);會轉用 RMT method 兼唔限用邊支 pin,第一粒同最尾粒可以有較減小 latency 。我買左比 wemos d1 mini 更細嘅 ESP32C3 supermini 或者叫 mini pro 方便連埋圓形 ws2812 放入 CD 盒,再簡單用辣鷄喺盒邊開條 slot 攝粒 “7触点磁吸頭” 解決供電問題。ESP32C3 係內置 USB controller 用 CDC 模擬 UART,新買番黎時嘅 com port 會不断 disconnect 以為壞左,原來要柬啱 ESP32C3 而唔係 default 佢認嘅 “ESP32 Family” 先刷得入 rom。

93 pixels WS2812B with ESP32C3

一般形容 WS2812 都係四腳嘅 WS2812B,有燈條同 PCB,我買左圓型嘅 PCB版番黎睇 Hue 漸變兼諗住用黎造RGB攝影補光燈,買左之發現原來每個圓之間係無電路需要自己銲番埋。從 spec 可以睇倒 RGB 唔等同芒嘅三原色,當中藍色一樣,紅色 620nm 比 sRGB 嘅紅色 610nm 紅小小但唔明顯,最大分別既綠色係 520nm 同 550nm 之差,接近樹葉嘅馴綠色。WS2812 簡單用 library 內置嘅 HSLcolor() 或者 HSBcolor() 轉色嘅話,再 Serial.print(R,G,B) 数值出黎睇,就會發現由一隻原色連續幾粒 255 保持最光先過渡到令一隻原色,所以整個 Hue 嘅色彩漸變缺乏中間色,而且中間色會特別光啲。

有 Copliot 輔助之下,原本超越我能力範圍嘅曲衣家變得有可能,但試左幾段佢 gen 嘅曲效果都唔理想,會有誤解同溝通上嘅問題,最終係要唔用 library 內置嘅 HSBcolor(),而自己寫個 HSBtoRGB(),將所有調較嘅数值放晒入去return RgbColor,用個 fn_HSBtoRGB.h 包住方便 include。

RgbColor HSBtoRGB(float hue, float saturation, float brightness) {
  const uint8_t maxR = 75;
  const uint8_t maxG = 95;
  const uint8_t maxB = 248; //clipping
  const float minR = 8.7;
  const float minG = 8.7;
  const float minB = 9.7;
  const float Rwhite = 1.0; // white balance multipliers
  const float Gwhite = 0.9;
  const float Bwhite = 0.7;
  const float gamma = 2.0;
  const float HUE_G = 0.4; //ws2812 520nm instead of 550nm
  const float HUE_B = 0.6;

  float h = hue - floor(hue);
  float rf = 0, gf = 0, bf = 0;
  if (h < HUE_G) {
    float ratio = h / HUE_G;
    rf = 1.0f - ratio;
    gf = ratio;
    bf = 0.0f;
  } else if (h < HUE_B) {
    float ratio = (h - HUE_G) / (HUE_B - HUE_G);
    rf = 0.0f;
    gf = 1.0f - ratio;
    bf = ratio;
  } else {
    float ratio = (h - HUE_B) / (1.0f - HUE_B);
    rf = ratio;
    gf = 0.0f;
    bf = 1.0f - ratio;
  }

  // brightness & gamma correction
  float R = rf * (minR + pow(brightness, gamma) * (maxR - minR));
  float G = gf * (minG + pow(brightness, gamma) * (maxG - minG));
  float B = bf * (minB + pow(brightness, gamma) * (maxB - minB));

  // desaturation: 
  float white = R + G + B;
  R = R * saturation + white * (1.0f - saturation) * Rwhite;
  G = G * saturation + white * (1.0f - saturation) * Gwhite;
  B = B * saturation + white * (1.0f - saturation) * Bwhite;

  uint8_t r8 = constrain(uint8_t(R + 1), 1, 255); //minium 1 for smooth color 
  uint8_t g8 = constrain(uint8_t(G + 1), 1, 255);
  uint8_t b8 = constrain(uint8_t(B + 1), 1, 255);
  if (brightness == 0.0) { // black
    r8 = g8 = b8 = 0;
  }
  return RgbColor(r8, g8, b8);
}

因為 8266 用 DMA method 喺 interrupt 度行 ws2812.Show() 就會造成 boot 机,於是要有個 functino 放喺 loop() 度 check 過有資訊改變先 write 落 ws2812。

#include <NeoPixelBus.h>
#include <fn_HSBtoRGB.h>

#if defined(CONFIG_IDF_TARGET_ESP32C3) 
const uint8_t PIN_WS = 3;  // RMT method
#else  // WEMOS D1 R2 & D1 mini 8266
const uint8_t PIN_WS = 3;  // PIN_RX, DMA+I2C method
#endif

const uint8_t PIXEL_COUNT = 93;
const uint8_t PIXEL_ARRAY[] = { 1, 8, 12, 16, 24, 32 };
const uint8_t PIXEL_START[] = { 0, 1, 9, 21, 37, 61 };
const uint8_t PIXEL_END[] = { 0, 8, 20, 36, 60, 92 };

bool is_ws2812_changed = false;  // flag to indicate if ws2812 has changed
void fn_ws2812_hsb(float hue, float saturation = 1.0, float brightness = 1.0, int8_t pixel = -1, bool serialPruint8_t = true);
void fn_ws2812_write();
NeoPixelBus<NeoGrbFeature, NeoWs2812xMethod> ws2812(PIXEL_COUNT, PIN_WS);

void setup() {
  Serial.begin(115200);
  Serial.println("");
  ws2812.Begin();
}

void loop() {
  test_hsb();
  fn_ws2812_write();
  delay(30000);
  test_hsb_dark();
  fn_ws2812_write();
  delay(30000);
}
                  
//*******************************************************************************************
// functions
//******************************************************************************************* 

 void test_hsb() {
  for (int g = 0; g < 6; g++) {
    for (int i = PIXEL_END[g]; i >= PIXEL_START[g]; i--) {
      float h = (1.0 / PIXEL_ARRAY[g]) * (PIXEL_START[g] - i);
      if (g == 5) {
        h += 0.0156; //1/64 angle correction for outermost ring
      }
      float s = g * 0.2; // saturation: 0.2 to 1.0
      fn_ws2812_hsb(h, s, 1.0, i);
    }
  }
}

void test_hsb_dark() {
  for (int g = 0; g < 6; g++) {
    for (int i = PIXEL_END[g]; i >= PIXEL_START[g]; i--) {
      float h = (1.0 / PIXEL_ARRAY[g]) * (PIXEL_START[g] - i);
      if (g == 5) {
        h += 0.0156; //1/64 angle correction for outermost ring
      }
      float b = g * 0.2; // brighness: 0.2 to 1.0
      fn_ws2812_hsb(h, 1.0, b, i);
    }
  }
}   

void fn_ws2812_hsb(float hue, float saturation, float brightness, int8_t pixel, bool serialPrint) {
  RgbColor color = HSBtoRGB(hue, saturation, brightness);
  if (pixel < 0) { // all same
    bool changed = false;
    for (uint8_t i = 0; i < ws2812.PixelCount(); i++) {
      if (ws2812.GetPixelColor(i) != color) {
        ws2812.SetPixelColor(i, color);
        changed = true;
      }
    }

    if (changed) is_ws2812_changed = true;
  } else if (pixel < ws2812.PixelCount()) {
    if (ws2812.GetPixelColor(pixel) != color) {
      ws2812.SetPixelColor(pixel, color);
      is_ws2812_changed = true;
    }
  }
  if (serialPrint) {
    Serial.print(color.R);
    Serial.print(",");
    Serial.print(color.G);
    Serial.print(",");
    Serial.println(color.B);
  };
  //ws2812.Show(); 8266 can't interrupt, use loop_40ms fn_ws2812_write()
}

void fn_ws2812_write() {
  if (is_ws2812_changed) {
    ws2812.Show();
    is_ws2812_changed = false;
  }
}

現時嘅 sRGB 嘅三原色係建於白色分配比例大約係 (R:G:B) = (127,255,42),三隻色唔係一樣光度跟本唔合理,應該好似音響 frequency response 咁計埋人眼人耳感応曲線之後保持平直就得;ws2812 都係一樣三個数一樣就係白色,但比 sRGB 色温高啲感覺再白啲。我用人眼較到三隻色一樣光個数係 (75,105,248),即係話綠色由 550nm 改為 520nm 之後,紅色取而代之變成最光,而藍色要盡光得黎避免光到盡有 clipping 無變化。三原色平均之後,關鍵在於混色時改左用 linear interpolation,造到整個 Hue 光度都係一樣,咁仲有個好處係無論 Hue 数幾多, RGB 数值加埋就係白色造埋 desaluation,衣個值計出黎係 rgb(75,75,75) 受最低嘅紅色值,或者唔受 ws2812 混色計算影響嘅話,白光應該係所有原色一半光度嘅總和,而唔係 sRGB咁開到最光令三原色同白色光度有好大差別;所以為左色階變化平均,所有色都會受制於最暗嘅藍色而變得暗左好多。

brightness with gamma = 2 vary to black

sRGB 嘅 gamma 係 2.2,ws2812 default 無 gamma 修正相當於 gamma = 1 即係 linear scale,原本我以為 2 或者 2.7 即係 natural log 先符合物理學,但後來查番 2.2 個数來自 CRT 年代 1998 年 Ebner and Fairchild 對人眼灰階亮度嘅實驗結果係 0.43,1/0.43 = 2.33。類似嘅實險其實可以自己造,只要用 127 中灰打段字喺白底同黑底比較吓,就會發現白底易睇啲,所以我覺得中灰應該係光番啲即係 gamma 個数低啲先啱,最後我覺得 gamma 簡單係 2 就得,即係人眼係 log-0.5 scale 造到 HDR 效果睇倒好強同好弱嘅光度。gamma 個数喺 HSB 之中只需要乘 brighness 個数就得,對 Hue 變化無影響。

HSL vs HSB color model

對比 HSB 同個頂係白色嘅 HSL color model 之後,HSB 可以拆開鮮色到白色係 linear scale,鮮色到黑色係 log-0.5 scale 變得更合理;亦可以想像成原本現實係黑色中心圓底嘅 “半球体”,人眼經過 log-0.5 scale 處理之後,變成長身啲黑底嘅 “圓筒形”。HSL 嘅 L 係0.5 最鮮色,明顯理解為圓筒形高度係 HSB 嘅一倍,但因為人眼光度比彩度變化敏感得多,灰到鮮色比HSB白到鮮色嘅資訊量會小啲,所以 HSL 係直徑細啲長身啲嘅圓筒形,而 HSB 係直徑大啲同高度一樣嘅正圓筒形,両者嘅体積應該差唔多起唔倒節省資訊量嘅作用。

simulate HSB model brightness = 1

到最後效果最有凸破性提升嘅始終要靠自己去諗,我試左好耐 minimum value 定乜数,結果發現如果想漸變色階理想嘅話,關鍵中在於数值唔可以 0,最低要 1,rgb(1,1,1) 先係黑色睇落一啲都唔黑,所以 0 到 1 只限 ilnear scale 係同 1 到 2 一樣,一有log scale 出現就一定要由 1 開始,係好基本嘅数學常識,但唔係 “背答案” 特質嘅 AI 可以諗倒問題會同衣樣有關。 出黎嘅效果係所有色尤其是深色會粉左小小,咁先似影相 s/n ratio 唔會無限大影倒馴黑,color filter 其實係半透同時又吸倒啲 noise 有 dynamic range 個数,而 ws2812 一定唔會高得去邊,尤其是加左均光板之後更加再粉色一啲,但對睇 Hue 漸變遠比 sRGB 嘅 LCD 芒效果好得多 ,雖然都係出唔倒馴黑,但 typical dynamic range 大約有 1000 其實遠高於 8bit 256個数但只限白色, RGB 三原色尤其是藍色相對白色暗好多有可能只得 6bit 色階。

sRGB 基本上係色階變化等分,可以將 Hue 分成三份,ws2812 嘅綠色偏藍,藍至綠嘅變化明顯小啲,綠色更加馴同時唔影響黃色嘅表現,另一邊藍至紅嘅色階變化比我相像中多,於是 Hue 唔應該等分三分要調埋 ratio,基本上紅色無論去綠定去藍果邊都提供關鍵色彩變化作用,我預到 4:2:4,綠至藍只佔 Hue 嘅 20%,而且 ws2812 嘅紅色如果用 660nm 而唔係 620nm 一定會更接近馴紅色階表現更理想。

Hue even disturbution base on wavelength

ws2812 Hue 嘅效果證明 CIE chart 嘅色彩間距唔可靠,反而簡單將圓形分 12 份之後,由 490nm 至 610nm 用每 20nm 為一格去分,已經係相當唔錯嘅色階變化參考。

Reference: