前回に引き続き、今回は画像変換やESP32のプログラムの解説になるのだが、後に続く解説の基礎を担う大事な回となっているので一読の価値あり。
前回はこちら
画像変換
変換の概要
円形POVでは縦x横の画像を半径x分解能の画像へと変形する必要があります。半径はLEDの数、1周の分解能は(360度÷表示する角度)となりますが、分解能については一番外側がLEDのサイズよりも小さいことだと考えているので、直径x3.14÷LEDのサイズ、が最大値だと思います。
今回は90mmの基板に30個の2mmサイズLEDを載せたものを回すため、半径は30(個)、分解能は[円周]÷[LEDサイズ]=[90(mm)x2x3.14]÷[2(mm)]で283とします。
画像の変換にはPythonを使用しています。変換の方法は、一辺がLEDの数x2の正方形に画像を縮小してから、回転角度に沿ってそれぞれのピクセルの色を取得するというものです。もう少し画像の劣化を防ぐ方法のアイデアはありますが、一番シンプルなので今回はこの方式を採用しました。
※画像の劣化を防ぐアイデア…画像のサイズ縮小を行わず、回転角度に沿って扇形状内の全ての画素の色を取得して足す。全ての画素の色を取得後、扇形状内に含まれる画素数で色を割る。
Pythonプログラム
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# -*- coding: utf-8 -*- from PIL import Image import numpy as np import math import os #ガンマ補正値 gammaR = 5.0 gammaG = 5.0 gammaB = 5.0 #LEDの数を設定 LEDNum = 30 np.set_printoptions(threshold=np.inf)#printの表示を省略しない dirName = '画像があるフォルダのアドレス' fileName = '画像ファイル名' # 元となる画像の読み込み img = Image.open(dirName + '\\' +fileName) #RGBAの場合、RGBに変換しておく img = img.convert("RGB") #出力画像の幅と高さを設定 width = LEDNum height = round(LEDNum*3*3.14*2/2) #リサイズ画像の幅と高さを設定 rewidth = LEDNum*2 reheight = LEDNum*2 #リサイズ img_resize = img.resize((rewidth, reheight), Image.LANCZOS) img_pixels = Image.new("RGB", (width,height)) for deg in range(height): for r in range(width): #画像中心から距離r,角度 2pi*deg/heightのピクセル位置を計算 x = math.floor(rewidth/2 + (r+0.5) * math.sin(2*math.pi * deg/height)) y = math.floor(reheight/2 - (r+0.5) * math.cos(2*math.pi * deg/height)) # getpixel((x,y))で左からx番目,上からy番目のピクセルの色を取得し、img_pixelsに追加する img_pixels.putpixel((r,deg),img_resize.getpixel((x,y))) # あとで計算しやすいようにnumpyのarrayに変換しておく print(img_pixels.size) img_pixels = np.array(img_pixels) print(img_pixels.shape) #画像の変換 img_dataRGB = np.zeros((height,width,3),dtype=np.uint8) img_data32 = np.zeros((height,width)) img_data16 = np.zeros((height,width)) for y2 in range(height): for x2 in range(width): R = img_pixels[y2,x2,0] G = img_pixels[y2,x2,1] B = img_pixels[y2,x2,2] #ガンマ補正 R = ((R/255.0)**gammaR)*255.0 G = ((G/255.0)**gammaG)*255.0 B = ((B/255.0)**gammaB)*255.0 #円中心と外側の明るさ補正 R = R*(x2+1)/width G = G*(x2+1)/width B = B*(x2+1)/width #整数値丸め R = round(R) G = round(G) B = round(B) img_dataRGB[y2,width-x2-1] = [R,G,B] Color32 = B*(2**16)+G*(2**8)+R img_data32[y2,width-x2-1] = Color32 Color16 = round(B/8)*(2**11)+round(G/4)*(2**5)+round(R/8) img_data16[y2,width-x2-1] = Color16 fileName2 = fileName.split('.')[:-1] dirName2 = dirName +'\\'+ 'forPOV' #変換画像収納フォルダの作成 if not os.path.exists(dirName2): os.mkdir(dirName2) #RGB(3x8bit)での出力 path_w = dirName2 +'\\'+ fileName2[0] + 'BGR.c' with open(path_w, mode='w') as f: f.write('//') f.write(fileName) f.write('\n') f.write('const unsigned char imageBGR[') f.write(str(height)) f.write('][') f.write(str(width*3)) f.write('] = {\n') for y2 in range(height): f.write('{') for x2 in range(width): f.write(format(int(img_dataRGB[y2,x2,2]),'#04x')) f.write(',') f.write(format(int(img_dataRGB[y2,x2,1]),'#04x')) f.write(',') f.write(format(int(img_dataRGB[y2,x2,0]),'#04x')) if x2<(width-1): f.write(',') if y2<(height-1): f.write('},\n') f.write('}\n};') #32bit(実際は3x8bitの24bitカラー)での出力 path_w = dirName2 +'\\'+ fileName2[0] + '32.c' with open(path_w, mode='w') as f: f.write('//') f.write(fileName) f.write('\n') f.write('const unsigned long image32[') f.write(str(height)) f.write('][') f.write(str(width)) f.write('] = {\n') for y2 in range(height): f.write('{') for x2 in range(width): f.write(format(int(img_data32[y2,x2]),'#08x')) if x2<(width-1): f.write(',') if y2<(height-1): f.write('},\n') f.write('}\n};') #16bitカラーでの出力 path_w = dirName2 +'\\'+ fileName2[0] + '16.c' with open(path_w, mode='w') as f: f.write('//') f.write(fileName) f.write('\n') f.write('const unsigned short image16[') f.write(str(height)) f.write('][') f.write(str(width)) f.write('] = {\n') for y2 in range(height): f.write('{') for x2 in range(width): f.write(format(int(img_data16[y2,x2]),'#06x')) if x2<(width-1): f.write(',') if y2<(height-1): f.write('},\n') f.write('}\n};') #画像の保存 #print(img_dataRGB.shape) path_w2 = dirName2 +'\\'+ fileName2[0] + '.bmp' img2 = Image.fromarray(img_dataRGB.astype(np.uint8))#uint8に変換しないとエラーになる #img2 = img2.convert("L") img2.save(path_w2) print(img2) #binファイルの保存 path_w3 = dirName2 +'\\'+ fileName2[0] + '.bin' f3 = open(path_w3, mode='wb') # バイナリ変換 img_bin = np.ravel(img_dataRGB).tobytes() print(len(img_bin)) print(img_bin) f3.write(img_bin) f3.close() |
画像変換プログラムについての補足
ガンマ補正(9~11行目)
画像の色をそのままLEDで表示すると、明るめの色が白飛びしてしまうのでガンマ補正という方法で明るさを抑えています。普通の画像変換ではガンマ補正は±1.0ぐらいまでなのですが、LEDで本来の色を再現しようとすると5.0ぐらいの強烈な値を設定する必要がありました。(この値は各々調整してください)
円中心と外側の明るさ補正(64~66行目)
円形POVは円の中心に近いほどLEDの回転速度が遅くなるため、中心ほど明るくなります。外側と中心の明るさを揃えるため、中心からの距離が近くなるにつれて明るさを下げています。
出力されるファイル
このプログラムで出力されるファイルには以下のものがあります。保存先は画像のあったフォルダに「forPOV」フォルダが作成され、その中に保存されます。
・[画像名]BGR.c…RGB各8bitのChar配列を3つずつ並べた24bitカラーの配列を出力します。配列の並びとしてはB,G,Rの順です。内蔵フラッシュメモリに書き込む画像データは基本的にこれを使います。
・[画像名]32.c…RGB24bitカラーをunsigned long(32bit:0x8+Bx8+Gx8+Rx8)の配列として並べたものを出力します。再現される色はBGR.cと同じですが、ファイルサイズが32bit/24bit =4/3倍になるので、フラッシュメモリに複数枚のデータを保存するときには不向きです。
・[画像名]16.c…BBBBBGGGGGGRRRRRの並びの16bitカラーをunsigned short(16bit)の配列として出力します。24bitカラーよりも色の再現は悪くなりますが、データサイズは節約できます。
・[画像名].bmp…円形POV用に変形したBitmap画像を出力します(つまりは変換の概要で説明した縦長の画像)。カラーは24bitカラーです。画像ファイルなので出力結果を見ることができます。また、SDカードからデータを読み込む場合にはこの形式で保存します。
・[画像名].bin…24bitカラーのデータをバイナリファイルで保存しています。今回は使っていませんが、16bitカラーのファイルでSDカードに保存する場合など、将来的な参照のために記載しています。
実際に変換した結果
この画像を変換しました。ファイル種類としては透過PNGとなり、透過部分は黒として判定されます。
ESP32用プログラム
今回はESP32の内蔵フラッシュメモリに画像データを書き込む方法をとります。フラッシュメモリは電源を切っても内容が消えないので主にプログラムの保管先となっていますが、ある程度の大きさのデータを入れておくこともできます(容量はESP32の種類によって変わりますが2~16MB程度です)。フラッシュメモリへの書き込み方法としてはいくつかの方法がありますが、.cのファイルを追加する方法を採用しています。
画像データの追加方法
Pythonで出力した.cファイルをArduinoエディタ上にドラッグ・アンド・ドロップして追加します。また、プログラムで「#include “MFTLogo_sqRGB.c”」というふうに記載します。
Pythonで出力した画像データのダウンロードはコチラ
プログラム(Arduinoで記載)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
#define GPIO_0to31_REG *((volatile unsigned long *)GPIO_OUT_REG) #define GPIO_0to31SET_REG *((volatile unsigned long *)GPIO_OUT_W1TS_REG) #define GPIO_0to31CLR_REG *((volatile unsigned long *)GPIO_OUT_W1TC_REG) #include "MFTLogo_sq16.c" #include "MFTLogo_sq32.c" #include "MFTLogo_sqBGR.c" #include <SPI.h> // Define which pins to use. //VSPI:18SCLK,23MOSI,19MISO,5CS //HSPI:14SCLK,13MOSI,12MISO,15CS #define sensorPin 27 //回転検出センサ入力 #define dataPin 13 //LEDクロック(HSPI使用時は不要) #define clockPin 14 //LEDデータ(HSPI使用時は不要) SPIClass hspi(HSPI); const bool SPIEnable = false; //SPIをLEDの通信に使うかどうか(false:GPIOをLEDの通信に使う) // Set the number of LEDs to control. const uint16_t LEDNum = 30; //LEDの数 const uint16_t circleNum = 283; //一周を何分解するか const uint16_t startDeg = 120; //回転検出センサの位置(0~359度) // Set the brightness to use (the maximum is 31). uint8_t brightness = 8; //Display Setting //0:BGR 1:32bit 2:16bit int imageNum = 0; //Timing to drawing unsigned long drawTime; unsigned long preTime; unsigned long nowTime; unsigned long cycleTime; unsigned long rawCycleTime; unsigned long cycleTimeTh = 20000; //誤検知防止用のしきい値[us] bool drawFlag; byte sendData[LEDNum*4+9]; //回転検出センサの割り込み void Interrupt(){ nowTime = micros(); rawCycleTime = nowTime-preTime; //回転検出センサの誤入力を除外 if(rawCycleTime>cycleTimeTh){ cycleTime = rawCycleTime; drawTime = cycleTime/circleNum; preTime = nowTime; drawFlag = true; } } void setup() { pinMode(sensorPin,INPUT); Serial.begin(115200); while (!Serial) { delay(1); // wait for serial port to connect. } //SPI設定、もしくはGPIOの出力設定 if(SPIEnable){ hspi.begin(14, 12, 13, 15); hspi.setFrequency(30000000); hspi.setDataMode(SPI_MODE3); hspi.setHwCs(true); } else{ pinMode(dataPin,OUTPUT); pinMode(clockPin,OUTPUT); } //割り込み設定 cycleTime = 500000; preTime = 0; //attachInterrupt(sensorPin, Interrupt, RISING); attachInterrupt(sensorPin, Interrupt, FALLING); drawFlag = false; //LEDに送信するデータ配列の準備(開始Byte/終了Byte/明るさ) for(int i=0;i<sizeof(sendData);i++){ if(i<4){ sendData[i] = 0x00; } else if(i>=sizeof(sendData)-5){ sendData[i] = 0xFF; } else if(i%4 == 0){ sendData[i] = 224+brightness; } } Serial.println("cycleTime,initialTime,memcpyTime,sendDataTime"); } void loop() { //drawFlag = true;//回転させずともLEDにデータを流す。デバッグ用 if(drawFlag){ drawFlag=false; unsigned long nextDrawTime = micros(); //描画開始位置の調整 uint16_t startPos = round((float)circleNum * (float)startDeg/360.0); uint16_t h = startPos; //計測時間の変数定義 uint32_t testTime; uint32_t initialTime = 0; uint32_t memcpyTime = 0; uint32_t sendDataTime = 0; //データ読み込みとLEDへの書き込み while(1){ //nextDrawTimeになるまで待つ if(nextDrawTime<=micros()){ testTime = micros(); nextDrawTime += drawTime; uint16_t h2; if(h<circleNum/2) h2=h+circleNum/2; else h2=h-circleNum/2; uint32_t data32[LEDNum]; uint16_t data16[LEDNum]; uint8_t dataBGR[LEDNum*3]; initialTime += micros()-testTime; //BGRx8bitデータ if(imageNum == 0){ testTime = micros(); //フラッシュメモリから配列へのコピー memcpy(dataBGR, imageBGR[h], sizeof(dataBGR)); //LEDへ送るデータ配列に色情報を入力 for(int i=0;i<LEDNum;i++){ sendData[4*i+5] = dataBGR[3*i]; sendData[4*i+6] = dataBGR[3*i+1]; sendData[4*i+7] = dataBGR[3*i+2]; } memcpyTime += micros()-testTime; } //32bitデータ else if(imageNum == 1){ testTime = micros(); //フラッシュメモリから配列へのコピー memcpy(data32, image32[h], sizeof(data32)); //LEDへ送るデータ配列に色情報を入力 for(int i=0;i<LEDNum;i++){ sendData[4*i+5] = (data32[i]&0xFF0000)>>16; sendData[4*i+6] = (data32[i]&0x00FF00)>>8; sendData[4*i+7] = data32[i]&0x0000FF; } memcpyTime += micros()-testTime; } //16bitカラー else if(imageNum == 2){ testTime = micros(); //フラッシュメモリから配列へのコピー memcpy(data16, image16[h], sizeof(data16)); //LEDへ送るデータ配列に色情報を入力 for(int i=0;i<LEDNum;i++){ sendData[4*i+5] = (data16[i]&0xF800)>>8; sendData[4*i+6] = (data16[i]&0x07E0)>>3; sendData[4*i+7] = (data16[i]&0x001F)<<3; } memcpyTime += micros()-testTime; } //LEDへデータを送信 testTime = micros(); if(SPIEnable){ hspi.writeBytes(sendData,sizeof(sendData)); } else{ writeBytes(sendData,sizeof(sendData)); } sendDataTime += micros()-testTime; h++; if(h == startPos)break;//hが一周したら終了 if(h == circleNum) h=0; } } Serial.printf("%ld,%ld,%ld,%ld\n",cycleTime,initialTime,memcpyTime,sendDataTime); } } void writeBytes(uint8_t *dat, uint16_t datSize){ for(uint16_t i=0;i<datSize;i++){ GPIO_0to31_REG = (dat[i]>>7&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>6&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>5&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>4&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>3&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>2&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>1&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH GPIO_0to31_REG = (dat[i]>>0&1)<<dataPin; //Clock:LOW GPIO_0to31SET_REG = 1<<clockPin; //Clock:HIGH } } |
プログラムの流れ
- ホールセンサの入力を割込みで検出する(79行)
- ホールセンサの入力の時間から回転の一周の時間、一周を分解能で割った時間(LEDアレイに書き込む間隔:drawTime)を算出、LEDへの書き込み(drawFlag)を有効にする(43~53行)
- (Loop内)drawFlagがTrueの時にLEDアレイの書き込みを開始する(101行)
- While(1)で無限ループにする(116行)
- 回転の始点の角度が0度以外の場合、画像の途中の行からの描画になるので、描画する行(h)に回転の始点の角度(startDeg)から算出した開始行を代入する(106~107行)
- 現在の時間≧前に書き込んだ時の時間+drawTime=nextDrawTimeになるまで待つ(118行)
- フラッシュメモリから変数配列(dataBGR)に画像データの1行分だけコピーする(136行)
- LEDアレイに送信するためのデータ配列へと加工して変数配列(sendData)に保存する(139~143行)
- SPIでLEDアレイにデータを送信する(180行)
- 描画する行(h)に1を足す
- ⑤~⑨を繰り返す。hが開始行になったらWhileループを抜ける
プログラムの補足
- 13~15行:GPIOピンの設定を行います。SPIを使う場合、dataPin,ClockPinの設定は不要です。逆にSPIを使わない場合、dataPin,ClockPinはG0~31のどれかに設定できます(ESP32のプログラムや動作に影響のあるピンを除く)。
- 17行:SPIを使うか、GPIOのアップダウンで通信を行うかの設定です。詳しくは後述しますがSPIは最大80MHz、GPIOは最大10MHzで通信できます。
- 20~22行:POVの構造によって値を変更する場所です。startDegはホールセンサが(時計で言う)12時の方向にある時が0度、4時の方向にある時が120度です。なお、LEDの回転方向は時計回りになるようにしてください。
- 25行:LEDの明るさで、LEDの通信の時に使います。0~31の値を設定できますが、あまり明るすぎると電流量が跳ね上がります。
- 29行:画像の形式を変更できます。0が通常のBGRx8bitで、1が32bit(BGRと画質は一緒)、2が16bit(BGRよりも画質が落ちるが、データサイズとデータの読み込みが速い)
- 37、83~93行:ESP32の割り込みの特性なのかなんなのかわからないのですが、時々ホールセンサの入力を2回分カウントしているようで、そのときには回転速度がめっちゃ上がっていると判定されてしまうので、しきい値を設けています。20000usだと50回転/秒なため、こんなに速くは回さないよねという値にします。(ちなみに使用したホールセンサはシュミットトリガ的な機能があるので、センサーとしてフォトリフレクタを使った時のようにシュミットトリガICを入れる必要はありません。実際にシュミットトリガICを経由して割り込み信号に入れましたが結果は同じでした。)
- 66行:SPIの周波数を設定します。SPIは最大80MHzまで設定できるようですが、HD107S LEDの通信速度の最大が(データシート上では)30MHzなのでsetFrequencyは30,000,000としています(ただ、実物で確認すると40MHzまでいけそうです)。ESP32は他のピンをハードSPIにできるという情報もありますが未検証です(その場合は最大40MHzとのこと)。
- 83~93行:SPIで送信すると、信号を送信してから次の信号を送信するまでに間が空きます。そのため、最小時間で信号を送信するとなるとLEDの数分一気に送る必要があるため、sendDataという配列を設けています。構造としては、[0x00が4つ][輝度,G,B,R][0xffが5つ]という構成です。最後の0xffが5つなのは、最後の通信は([LEDの数]+ 14)/16を0xffにすると決められているため、LEDが66~81個までの時は5回以上0xffを流す必要があります。82個以上では6回以上流す必要があります。(なのでLED10個では2回で良いとは思いますが念のため…)
- 100行目:この行を有効にするとホールセンサの入力に関わらず、LEDへの描画を繰り返すことになります。そのためLEDアレイを回転しないでLEDが光るかどうかを確認する時に有効にします。
- 201~219行:SPIを使用しない、GPIOのオンオフで行う場合の通信になります。レジスタの値を直接変えることで10MHzで通信できますが、G0~G31の値を一気に変えるため他にG0~G31の中に出力ピンがある場合は誤作動を起こす可能性があるため気をつける必要があります。逆に一気にGPIOを変更できることから、複数のLEDアレイと通信することもできるため4つ以上のLEDアレイに対して通信する場合、こちらの方が有利になります。ESP32のGPIOの高速化についてはこちらの記事を参照ください。
実際の見た目
とりあえず、ソフトウェア的に回転数のしきい値を設けて表示位置の問題は解決
— やまたい@ものつくり (@yamatai_mk) October 2, 2022
画質もやや改善#POV #バーサライタ pic.twitter.com/rJCe4U5sTa
シリアル通信出力と時間のハナシ
シリアル通信の出力は1周回るまでの【回転周期[us]:青】と、【準備時間[us]:赤】【フラッシュから変数配列へのコピー時間[us]:緑】【LEDアレイへの送信時間[us]:オレンジ】の合計を送信します。
【回転周期[us]】<【準備時間[us]】+【フラッシュから変数配列へのコピー時間[us]】+【LEDアレイへの送信時間[us]】となることが重要です。
これを見ると【準備時間[us]:赤】【フラッシュから変数配列へのコピー時間[us]:緑】は無視できるレベルで、【LEDアレイへの送信時間[us]:オレンジ】が最も時間のかかる項目となっています。コピー時間は例えば16bitカラーにすることで短縮できますが、LEDアレイへの送信時間はLEDの通信速度がmax30MHzになっている以上早めることはできず、SPIで1列のLEDアレイに信号を送ることを捨て、パラレルに複数のLEDアレイへ信号を送ることを検討する必要があります。
【フラッシュから変数配列へのコピー時間[us]】と【LEDアレイへの送信時間[us]】の時間はLEDアレイの数に比例し、送信する間隔は分解能に反比例するので、全体でかかる時間は概ねLEDアレイの数の2乗に比例して大きくなります(面積が増えるからね)。1周にかかる時間はSPI使用時・LEDアレイ30個で約15ms位かかっているのでLEDアレイを60個にすると60msぐらいかかり1000/60=毎秒16.7回転より遅く回さないと描画が追いつかなくなります。
次回
次回はマルチコア及びVSPIを使った、SDカードから画像を読み込んでPOVに表示する方法について説明します。
コメント