我用 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。

一般形容 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咁開到最光令三原色同白色光度有好大差別;所以為左色階變化平均,所有色都會受制於最暗嘅藍色而變得暗左好多。

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 變化無影響。

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

到最後效果最有凸破性提升嘅始終要靠自己去諗,我試左好耐 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 一定會更接近馴紅色階表現更理想。

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