バーサライタ(POV)を作ってみたい　その2

# 目次
[1. はじめに](#1-はじめに)
[2. 構成検討](#2-構成検討)
[3. 設計](#3-設計)
[4. 作製](#4-作製)
[5. おわりに](#5-おわりに)

# 1. はじめに
バーサライタを作ってみたいシリーズの第2弾。
やっぱりLEDで何か絵を表示してみたいのです。
前回は回転体を作っていろいろ大変だったので、今回はシンプルに雑に作ることを優先することにしました。

https://qiita.com/ichihey/items/151f4e97d49bdd18d266


こちらを参考にさせて頂き、ライン式のものを作ってみることにしました。

https://prototype09.com/archives/629

# 2. 構成検討
マイコンにはM5Capsuleを使用します。
2023のコンテスト参加賞でもらったやつです。
バッテリで電源確保、IMUでジャイロ検知、SDカード付なのでこれだけで全て完結できそう。
LEDはAliexpressで購入した、テープ実装済みのWS2813を使用しました。
密密にLEDが欲しいので、1mあたり144チップ搭載のやつを買いました。
実装済みだとこの辺が一番密かなと思います。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112433/5cb32b7f-d770-52bf-8b4e-7bb0234be2ca.png)

# 3. 設計
ハード設計は無し。
ソフトは画像を配列化するプログラムと、M5を動かすプログラムの二つを作成しました。

### 画像変換
Copilotさんと相談して作成しました。
画像を読み込んで、arduinoで扱いやすそうなArray型に変換します。
今回LEDの数が64個だったので、3rgb×64×64ﾋﾟｸｾﾙ という感じで、
画像をimg_array[3][64][64]という配列に変換するようにしました。
なお、LEDが64なのは特に意図はなく、たまたまちょうどいい長さが64個だっただけです。

また、やりながら改善したアイテムですが、薄めの画像だと白が強すぎてうまく色味が表現できないので、ガンマ補正も入れました。
輝度と入力値がリニアにならないのに対して行う補正だそうです。
下記がとてもしっくり腹落ちする説明でした。

https://e-words.jp/w/%E3%82%AC%E3%83%B3%E3%83%9E%E5%80%A4.html

なんやかんやとcopilotと作った画像変換コードがこちらです。

<details><summary><b><font color="#409202">╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╮<br>╏&nbsp;&nbsp;&nbsp;&nbsp;<font color="#409202">ソースコードを表示（折りたたみ）</font>&nbsp;&nbsp;&nbsp;╏<br>╰╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╯</font></b></summary><div>

```python
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt


# 画像ファイルを開く
img = Image.open(r'G:\マイドライブ\****')

# 画像を64x64のサイズにリサイズ
img = img.resize((64, 64))

# ガンマ値
gamma1 = 1.0
gamma2 = 0.4

# 画像データをnumpy配列に変換し、8ビットの整数に変換
img_array = np.array(img, dtype=np.float32) / 255

# ガンマ補正1
img_array1 = np.power(img_array, 1/gamma1)
# 8ビットの整数に変換
img_array1 = (img_array1 * 255).astype(np.uint8)

# ガンマ補正2
img_array2 = np.power(img_array, 1/gamma2)
# 8ビットの整数に変換
img_array2 = (img_array2 * 255).astype(np.uint8)

# 配列をinoファイルに出力
with open(r'G:\マイドライブ\***.ino', 'w') as f:
    f.write("const PROGMEM uint8_t img_array[3][64][64] = {\n")
    for color in range(3):  # RGBの3チャンネル
        f.write("{\n")
        for row in img_array2:
            f.write("{")
            f.write(",".join(str(row[i][color]) for i in range(64)))  # 64ピクセル
            f.write("},\n")
        f.write("},\n")
    f.write("};\n")

# 画像を表示
plt.figure(figsize=(10, 5))

plt.subplot(1, 2, 1)
plt.title(f'Gamma = {gamma1}')
plt.imshow(img_array1)

plt.subplot(1, 2, 2)
plt.title(f'Gamma = {gamma2}')
plt.imshow(img_array2)

plt.show()
```
</div></details>

いい感じのガンマ補正値は画像を見ながら調整しました。
白を抑えたいので、淡い画像ほど補正値を低くしています。
タピオカなどは顕著ですが、色味はクッキリしたほうがこういう表示には向いてますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112433/b7e88d31-a0dc-25c3-b7f9-0953e28d3e36.png)


### M5capsuleコード
IMUを使い、振ったときにLEDが点灯されるようにしました。
先の初音ミクさんの参考ブログでもありましたが、加速度センサよりジャイロ軸のほうがうまく検知できたので、そちらを使ってます。
ヨー軸を閾値決めているだけのシンプルなものです。
M5Capsuleの軸についてはこちらで簡単ですが調べました。

https://qiita.com/ichihey/items/061031d45a731a4f1cc6

<iframe width="343" height="610" src="https://www.youtube.com/embed/7LICBeVYuko" title="M5capsuleのIMU軸を確認" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>


作製したM5capsuleのコードはこちらです。
画像データは先のpythonで作成した画像のarrayをimg_array1.ino, img_array2.ino,･･･とリネームして、、一緒にコンパイルしてやります。
で、ボタン押しで切り替えていく感じ。

<details><summary><b><font color="#409202">╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╮<br>╏&nbsp;&nbsp;&nbsp;&nbsp;<font color="#409202">ソースコードを表示（折りたたみ）</font>&nbsp;&nbsp;&nbsp;╏<br>╰╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╯</font></b></summary><div>

```c++
#include "M5Capsule.h"
#include <M5Unified.h>
#include <Adafruit_NeoPixel.h>


//IMUの設定
static constexpr const uint8_t calib_value = 64;
static uint8_t calib_countdown = 0;
static int prev_xpos[18];
const int up1 = 5;//シリアルプロッタでのグラフ用下限
const int up2 = 500;//シリアルプロッタでのグラフ用上限
const int gz_treshold_plus = 200;
const int gz_treshold_minus = -200;

//neoPixelの設定
const int DIN_PIN = 15; //データ入力ピン
const int LED_COUNT = 64; //LEDの数
Adafruit_NeoPixel pixels(LED_COUNT, DIN_PIN, NEO_GRB + NEO_KHZ800);

//rainbow関数の設定
unsigned long pre_time = 0; //前回の時間
// unsigned long curr_time = 0; //現在の時間
#define MAX_VAL 10  // 最大輝度の設定をする(暗い←0 ～ 255→明い)
unsigned long off_time= 3000; //自動オフまでの時間

//sleepの設定
unsigned long sleep_time= 60000; //関数実行後、sleepまでの時間 msec
unsigned long lastExecutionTime = 0; // 最後に関数が実行された時間を記録

//画像表示系の設定
extern const PROGMEM uint8_t img_array1[3][64][64];
extern const PROGMEM uint8_t img_array2[3][64][64];
extern const PROGMEM uint8_t img_array3[3][64][64];
extern const PROGMEM uint8_t img_array4[3][64][64];
extern const PROGMEM uint8_t img_array5[3][64][64];
extern const PROGMEM uint8_t img_array6[3][64][64];
extern const PROGMEM uint8_t img_array7[3][64][64];
extern const PROGMEM uint8_t img_array8[3][64][64];
extern const PROGMEM uint8_t img_array9[3][64][64];
extern const PROGMEM uint8_t img_array10[3][64][64];
extern const PROGMEM uint8_t img_array11[3][64][64];

const PROGMEM uint8_t* img_arrays[] = {
    (const PROGMEM uint8_t*)img_array1,
    (const PROGMEM uint8_t*)img_array2,
    (const PROGMEM uint8_t*)img_array3,
    (const PROGMEM uint8_t*)img_array4,
    (const PROGMEM uint8_t*)img_array5,
    (const PROGMEM uint8_t*)img_array6,
    (const PROGMEM uint8_t*)img_array7,
    (const PROGMEM uint8_t*)img_array8,
    (const PROGMEM uint8_t*)img_array9,
    (const PROGMEM uint8_t*)img_array10,
    (const PROGMEM uint8_t*)img_array11,
};

const int MAX_ARRAYS = sizeof(img_arrays) / sizeof(img_arrays[0]);
int track =0;

//点灯の設定
const int val_ratio = 5;//初期10 高いほど暗くなる
const int show_time = 1000;//点灯時間uesc単位
unsigned long func_delayTime = 200; // 次の関数が実行されるまでの所定の時間（ミリ秒）
\

//IMUキャリブレーションの関数。M5unifiedのサンプルコードから引用
void updateCalibration(uint32_t c, bool clear = false)
{
  Serial.println("updateCalibration start");
  calib_countdown = c;

  if (c == 0) {
    clear = true;
  }

  if (clear)
  {
    memset(prev_xpos, 0, sizeof(prev_xpos));

    if (c)
    {
      M5.Imu.setCalibration(calib_value, calib_value, calib_value);
    }
    else
    {
      M5.Imu.setCalibration(0, 0, calib_value);
      M5.Imu.saveOffsetToNVS();
    }
  }
  Serial.println("updateCalibration done");
}

//IMUキャリブレーションの関数。M5unifiedのサンプルコードから引用
void startCalibration(void)
{
  updateCalibration(10, true);
}


//rainbow点灯パターン関数
uint32_t WheelRainbow(byte WheelPos) {
  WheelPos = 255 - WheelPos;
  if(WheelPos < 85) {
    return pixels.Color(map(WheelPos * 3, 0, 255, 0, MAX_VAL), map(255 - WheelPos * 3, 0, 255, 0, MAX_VAL), 0);
  } else if(WheelPos < 170) {
    WheelPos -= 85;
    return pixels.Color(map(255 - WheelPos * 3, 0, 255, 0, MAX_VAL), 0, map(WheelPos * 3, 0, 255, 0, MAX_VAL));
  } else {
    WheelPos -= 170;
    return pixels.Color(0, map(WheelPos * 3, 0, 255, 0, MAX_VAL), map(255 - WheelPos * 3, 0, 255, 0, MAX_VAL));
  }
}
//青色グラデーション点灯パターン関数
uint32_t WheelBlueCyan(byte WheelPos) {
  if (WheelPos < 128) {
    return pixels.Color(0, WheelPos * 2, 255);
  } else {
    WheelPos -= 128;
    return pixels.Color(0, (255 - WheelPos * 2), 255);
  }
}
//点灯パターンを格納する配列
typedef uint32_t (*WheelFuncPtr)(byte);
WheelFuncPtr wheelFunctions[] = {
  WheelRainbow,
  WheelBlueCyan,
};
//点灯パターンのリセット関数
uint32_t Wheelreset() {
  return pixels.Color(0,0,0);
}

//点灯パターンの実行関数
void LED_cycle(int currentFunction){
  Serial.println("LED_cycle start");
  for (int i = 0; i < pixels.numPixels(); i++) {
    pixels.setPixelColor(i, Wheelreset());

  }
  delay(100);
  pre_time = millis();
  Serial.print("pre_time:"+String(pre_time));
  bool flag = true;
  while(flag){
    for (int j = 0; j < 256 * 5; j++) {
        for (int i = 0; i < pixels.numPixels(); i++) { 
        pixels.setPixelColor(i, wheelFunctions[currentFunction]
        ((((pixels.numPixels() - 1 - i) * 256 / pixels.numPixels()) + j) & 255));
        }
        pixels.show();
        if(millis() - pre_time > off_time){
            Serial.println("LED cycle中に一定時間経過しました。");
            pixels.clear();
            pixels.show();
            flag = false;
            delay(100);
            break;
        }    
        delay(10);
    }    
  }   

  Serial.println("LED_cycle end");
}


//画像表示関数　順振り時
void led_line(){
  Serial.println("led_line start");
  const PROGMEM uint8_t (*selected_array)[64][64] = (const PROGMEM uint8_t (*)[64][64])img_arrays[track];
  for (int y = 0; y < 64; y++) {
    // Serial.print("{");
    for (int i = 0; i < LED_COUNT; i++) {
      int r=selected_array[0][y][i]/val_ratio;
      int g=selected_array[1][y][i]/val_ratio;
      int b=selected_array[2][y][i]/val_ratio;
      pixels.setPixelColor(LED_COUNT-i-1, pixels.Color(r, g, b));
      // Serial.print("{" + String(r) + "," + String(g) + "," + String(b)+"}");
    }
    // Serial.print("},");
    pixels.show();
    // Serial.println("y position:" + String(y));
    delayMicroseconds(show_time);  // 適切な遅延を設定
  }  
  pixels.clear();
  pixels.show();
  lastExecutionTime = millis(); // 最後に関数が実行された時間を記録
  Serial.println("led_line end");
}

//画像表示関数　逆振り時
void led_line_r(){
  Serial.println("led_line_r start");
  const PROGMEM uint8_t (*selected_array)[64][64] = (const PROGMEM uint8_t (*)[64][64])img_arrays[track];
  for (int y = 63; y >= 0; y--) {
    // Serial.print("{");
    for (int i = 0; i < LED_COUNT; i++) {
      int r=selected_array[0][y][i]/val_ratio;
      int g=selected_array[1][y][i]/val_ratio;
      int b=selected_array[2][y][i]/val_ratio;
      pixels.setPixelColor(LED_COUNT-i-1, pixels.Color(r, g, b));
      // Serial.print("{" + String(r) + "," + String(g) + "," + String(b)+"}");
    }
    // Serial.print("},");
    pixels.show();
    // Serial.println("y position:" + String(y));
    delayMicroseconds(show_time);  // 適切な遅延を設定
  }  
  pixels.clear();
  pixels.show();
  lastExecutionTime = millis(); // 最後に関数が実行された時間を記録
  Serial.println("led_line_r end");
}



void setup(void)
{
  //m5の初期設定
  auto cfg = M5.config();
  M5Capsule.begin(cfg);
  //IMUの初期設定
  const char* name;
  auto imu_type = M5.Imu.getType();
  switch (imu_type)
  {
  case m5::imu_none:        name = "not found";   break;
  case m5::imu_sh200q:      name = "sh200q";      break;
  case m5::imu_mpu6050:     name = "mpu6050";     break;
  case m5::imu_mpu6886:     name = "mpu6886";     break;
  case m5::imu_mpu9250:     name = "mpu9250";     break;
  case m5::imu_bmi270:      name = "bmi270";      break;
  default:                  name = "unknown";     break;
  };

  //IMUの種類を表示。M5capsule はBMI270
  Serial.println("IMU type: " + String(name));

  if (imu_type == m5::imu_none)
  {
    for (;;) { delay(1); }
  }

  if (!M5.Imu.loadOffsetFromNVS())
  {
    startCalibration();
  }
  M5.Imu.begin();

  //neoPixelの初期設定
  pixels.begin();
  pixels.show(); 
  
  //LEDCycleで起動点灯
  LED_cycle(0);

  Serial.println("setup done");
}



void loop(void)
{
  M5Capsule.update();
  auto imu_update = M5.Imu.update();
  auto data = M5.Imu.getImuData();
  // 振り終わり後は少しだけ待つ
  if (millis() - lastExecutionTime < func_delayTime) {
      Serial.println("loop関数を実行するのを待っています。");
      return; // loop関数を直ちに終了
  }
  //スリープする
  if(millis()-lastExecutionTime > sleep_time){
      Serial.println("一定時間経過しました。");
      esp_deep_sleep_start();
  }

  int ax = data.accel.x;
  int ay = data.accel.y;
  int az = data.accel.z;
  int gx = data.gyro.x;
  int gy = data.gyro.y;
  int gz = data.gyro.z;
  // Serial.println("gz:" + String(gz));
  if(gz > gz_treshold_plus){
      M5Capsule.Speaker.tone(10000, 20);
      led_line();
  }
  else if(gz < gz_treshold_minus){
      M5Capsule.Speaker.tone(10000, 20);
      led_line_r();
  }

  if (M5Capsule.BtnA.wasPressed()) {
      M5Capsule.Speaker.tone(10000, 20);
      track++;
      if (track >= MAX_ARRAYS) {
        track = 0;
      }
      Serial.println("next_track:" + String(track));
  }      
}

```
</div></details>

# 4. 作製
諸々実装したものがこちらです。
いい感じに雑に仕上がりました。手作り感あってとても良き。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112433/3e7148f6-47ae-28d6-4514-2962d39be801.png)


筐体的なものは作らずに定規に貼り付けることにしました。
安さ優先。
M5Capsuleは両面テープではっつけで対応。

https://jp.daisonet.com/products/4947678628487

スマホのナイトモードで撮影してみました。
存外きれいに表示されてます！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1112433/40ce2370-a09c-0234-9f07-c81a919ed65a.png)

動画はこちら。
<iframe width="343" height="610" src="https://www.youtube.com/embed/6OLPpEXQ0iQ" title="povを作ってみた" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

上下の振りもきちんと検知して、正常に描画できています。
これは中々楽しい！

# 5. おわりに
思ったよりシンプルな構成、コードで作ることができました。
ジャイロセンサは実験しながらざっくり閾値きめましたが、思った以上に正確に振りを検知してくれます。
うまく一定スピードで、ストレートに動かさないとうまく画像が描画できないけど、おもちゃとしてはこのくらいのほうが逆に楽しい。
あんまり作りこみすぎて簡単すぎないほうがいいところもありますね。
もっとLEDを敷き詰めて、解像度を上げてみたい気もしますが、表面実装が大変そうなのでまぁこの辺までかな。
音が出るようなにもしたいので、dfplayerと合体させてみようか。