🕺

Arduinoでマツケンを流そう

2024/12/28に公開

概要

Teensy4.1と秋月のILI9341ディスプレイでマツケンサンバⅡを再生します。
フレームレートは20fps、解像度は320×240px、音声はなしです。
これによってロボカップジュニアなど比較的小型のロボットでマツケンを流すことが現実的な選択肢になるといいなと思います。
今回は例としてマツケンを流していますが、基本的に正しく変換すればどんな動画でも流せます。

↓作成したものを再生している動画
https://x.com/koki1743/status/1726155704632029306?s=20

また今回のコードの作成に当たっては以下のサイトを参考にしました。感謝。
https://qiita.com/6186milktea/items/5057af5ee3b22049b811

マツケンサンバⅡ
https://www.youtube.com/watch?v=XazyhnymUQo

実行環境

※他のマイコン、SD等での動作は一切わかりかねます。

  • マイコン: Teensy4.1
  • ディスプレイ: ILI9341
  • マイクロSDカード: KIOXIA 16GB
    ※SDの種類やフォーマットの方式によって読み込み速度に大きな差があります。私の環境では20fps以上出ましたが、それを上回ることも下回ることも考えられます。
  • エディタ: VSCode - PlatformIO導入済み - 2023/11/21時点での最新バージョン
    ArduinoIDEでもそのまま実行できる(はず)

再生方法

マツケンサンバⅡを例に説明します。

0.マイクロSDカードをフォーマット

すでにFAT16かFAT32でフォーマットしている場合は追加でフォーマットする必要はないはずです。
私はKIOXIA 16GBをマイクロSDをRaspberry Pi ImagerのFAT32でフォーマットしました。(SDの前世がラズパイのストレージだったから)
このSDをWindowsエクスプローラーのFAT32でフォーマットしたところSDの速度が急激に下がりました。Raspberry Pi ImagerのFAT32でフォーマットし直すと復活しました。原因は全くわかりません。何かご存じの方は教えていただけるとうれしいです。

FAT16でも動きはするはずです。Teensy専用のSDFatライブラリを使えばexFATにも対応できるらしいです。
結局どのSDを使ってどの形式で何を使ってフォーマットすればいいかは片っ端から試してみるしかないのかもしれません。

1.動画を変換

ここはあくまでも自分の場合なのでお好みのツールでやってください

  1. 動画編集ソフトなどで動画の解像度を320×240の20fpsにする 。
    この時、エンコードは非圧縮のAVIでしておく。
    Davinci Resolve

    XMedia Recode
  2. 動画を非圧縮のBMPに変換する
    僕はVirtualDubを使いました。このソフトは非圧縮のAVIしか受け付けてくれないので注意

    マツケンの場合ここで6043個のファイルができます。
  3. BMP画像をRGB565の1つのファイルにまとめる
    Teensyに下記のコードを書き込んでください。
    ピンアサインは環境に合わせて変更してください。
    36行目の616を先ほど作成した静止画の名前の最大値にしてください(今回は6043)
    https://github.com/PaulStoffregen/ILI9341_t3/blob/master/examples/spitftbitmap/spitftbitmap.ino をもとに作成しました。
    #include <Arduino.h>
    #include <ILI9341_t3.h>
    #include <SPI.h>
    #include <SdFat.h>
    
    #define TFT_DC  9
    #define TFT_CS 37
    #define RESET 10
    #define SD_CS BUILTIN_SDCARD
    #define SD_CONFIG  SdioConfig(FIFO_SDIO)
    
    ILI9341_t3 tft = ILI9341_t3(TFT_CS, TFT_DC, RESET);
    SdFs sd;
    
    uint16_t read16(FsFile &f);
    uint32_t read32(FsFile &f);
    void bmpDraw(const char *filename, uint8_t xStart, uint16_t yStart, int num);
    
    void setup(void) {
      tft.begin();
      tft.setClock(60000000);
      tft.fillScreen(ILI9341_BLUE);
      tft.setRotation( 3 );
    
      Serial.begin(115200);
      tft.setTextColor(ILI9341_WHITE);
      tft.setTextSize(2);
      tft.println(F("Waiting for Arduino Serial Monitor..."));
      while (!Serial) {
        if (millis() > 1000) break;
      }
    
      Serial.print(F("Initializing SD card..."));
      tft.println(F("Init SD card..."));
      while (!sd.begin(SD_CONFIG)) {
        Serial.println(F("failed to access SD card!"));
        tft.println(F("failed to access SD card!"));
        delay(100);
      }
      Serial.println("OK!");
      for(int i = 0; i <= 6043; i++) {
        char fname[4];
        itoa(i, fname, 10);
        bmpDraw(fname, 0, 0, i);
      }
      delay(10);
    }
    
    void loop() {}
    
    #define BUFFPIXEL 240
    
    //===========================================================
    // Try Draw using writeRect
    void bmpDraw(const char *filename, uint8_t xStart, uint16_t yStart, int num) {
    
      FsFile     bmpFile;
      int      bmpWidth, bmpHeight;   // W+H in pixels
      // uint8_t  bmpDepth;              // Bit depth (currently must be 24)
      uint32_t bmpImageoffset;        // Start of image data in file
      uint32_t rowSize;               // Not always = bmpWidth; may have padding
      uint8_t  sdbuffer[3*BUFFPIXEL]; // pixel buffer (R+G+B per pixel)
      uint16_t buffidx = sizeof(sdbuffer); // Current position in sdbuffer
      boolean  flip    = true;        // BMP is stored bottom-to-top
      int      w, h, row, col;
      uint8_t  r, g, b;
      uint32_t pos = 0, startTime = millis();
    
      // Open requested file on SD card
      String fname_withBMP = String(filename) + ".bmp";
      const char* fname = fname_withBMP.c_str();
      bmpFile = sd.open(fname, FILE_READ);
      if (!bmpFile) {
        Serial.print("File not found");
        Serial.println(fname);
        return;
      }
    
      // Parse BMP header
      (void)read16(bmpFile); // 0x4D42  BMP signature
    
      (void)read32(bmpFile); // file size
      (void)read32(bmpFile); // Read & ignore creator bytes
      bmpImageoffset = read32(bmpFile); // Start of image data
      // Read DIB header
      (void)read32(bmpFile); // header size
      bmpWidth  = read32(bmpFile);
      bmpHeight = read32(bmpFile);
      (void)read16(bmpFile); // # planes -- must be '1'
      (void)read16(bmpFile); // bits per pixel must be 24
      (void)read32(bmpFile); // must be '0' // 0 = uncompressed
    
      // BMP rows are padded (if needed) to 4-byte boundary
      rowSize = (bmpWidth * 3 + 3) & ~3;
    
      // If bmpHeight is negative, image is in top-down order.
      // This is not canon but has been observed in the wild.
      if(bmpHeight < 0) {
        bmpHeight = -bmpHeight;
        flip      = false;
        // no
      }
    
      // Crop area to be loaded
      w = bmpWidth; // 240
      h = bmpHeight; // 320
    
      // 画像サイズ分のメモリを確保
      uint16_t **awColors = new uint16_t *[h];
      for (int i = 0; i < h; ++i) {
        awColors[i] = new uint16_t[w];
      }
    
      for (row=0; row<h; row++) { // For each scanline...
        if(flip) // Bitmap is stored bottom-to-top order (normal BMP)
          pos = bmpImageoffset + (bmpHeight - 1 - row) * rowSize;
        else     // Bitmap is stored top-to-bottom
          pos = bmpImageoffset + row * rowSize;
        if(bmpFile.position() != pos) { // Need seek?
          bmpFile.seek(pos);
          buffidx = sizeof(sdbuffer); // Force buffer reload
        }
    
        for (col=0; col<w; col++) { // For each pixel...
          // Time to read more pixel data?
          if (buffidx >= sizeof(sdbuffer)) { // Indeed
    	bmpFile.read(sdbuffer, sizeof(sdbuffer));
    	buffidx = 0; // Set index to beginning
          }
    
          // Convert pixel from BMP to TFT format, push to display
          b = sdbuffer[buffidx++];
          g = sdbuffer[buffidx++];
          r = sdbuffer[buffidx++];
          awColors[row][col] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
        }
      }
    
      // 範囲の端
      uint16_t xEnd = xStart+w-1;
      uint16_t yEnd = yStart+h-1;
      // 範囲外を淘汰せよ
      if (xEnd > 319) xEnd = 319;
      if (yEnd > 239) yEnd = 239;
    
      // 実際に描画する部分
      tft.beginSPITransaction(tft._clock);
      tft.setAddr(xStart, yStart, xEnd, yEnd); //書く範囲指定
      tft.writecommand_cont(ILI9341_RAMWR);
      for(int y=0; y<h; y++){
        for(int x=0; x<w; x++){
    	if(x==319 && y==239){//最後はwritedata16_last
    	      tft.writedata16_last(awColors[y][x]); 
    	      tft.endSPITransaction(); //描画終了
    	  }
    	else tft.writedata16_cont(awColors[y][x]); //ピクセル(x,y)描画
        }
      }
    
      FsFile dataFile = sd.open("METSUKEN.txt", FILE_WRITE);
    
      // if the file is available, write to it:
      if (dataFile) {
        if (num == 0){
          dataFile.write((const uint8_t *)&bmpHeight, sizeof(bmpHeight));
          dataFile.write((const uint8_t *)&bmpWidth, sizeof(bmpWidth));  
        }
        for(int i=0; i<h; i++){
          for(int j=0; j<w; j++){
    	// dataFile.println(awColors[i][j]);
    	dataFile.write((const uint8_t *)&awColors[i][j], sizeof(awColors[i][j]));
          }
        }
        dataFile.close();
      } else Serial.println("fail to access MATSUKEN.txt");
    
      // メモリ解放
    	for( int i=0; i<h; i++ ){
    		delete[] awColors[i];
    		awColors[i] = 0;
    	}
    	delete[] awColors;
    	awColors = 0;
    
      // かかった時間の表示
      Serial.print(F("Loaded in "));
      Serial.print(millis() - startTime);
      Serial.println(" ms");
    
      bmpFile.close();
    }
    
    uint16_t read16(FsFile &f) {
      uint16_t result;
      ((uint8_t *)&result)[0] = f.read(); // LSB
      ((uint8_t *)&result)[1] = f.read(); // MSB
      return result;
    }
    
    uint32_t read32(FsFile &f) {
      uint32_t result;
      ((uint8_t *)&result)[0] = f.read(); // LSB
      ((uint8_t *)&result)[1] = f.read();
      ((uint8_t *)&result)[2] = f.read();
      ((uint8_t *)&result)[3] = f.read(); // MSB
      return result;
    }
    
    書き込んだ段階ではSDカードが見つからないのでエラーメッセージがILI9341に表示されるはずです。
  4. 2.で作成したBMP画像をマイクロSDに書き込み、Teensyに差し込む
    すると、マツケンがゆっくりと再生されます
    • ここではBMP画像をRGB565の形式に変換しています
    • 元動画の3倍ちょっとの時間で終わるはずです
    • 理論上元のファイルの2/3倍のサイズのファイルたちが生成されるのでその分の容量の空きがSDにあるかも確認してください。
      マツケンの場合885MBのファイルが生成されました。

2. 再生

下記のコードを書き込むと動画が連続再生されます。
もしSD内に他のファイルがある場合は消すことでTeensyがファイルを検索する時間が短縮され、少し高速化される可能性があります。私はされませんでした。

#include <Arduino.h>
#include <ILI9341_t3.h>
#include <SdFat.h>

#define TFT_DC  9
#define TFT_CS 37
#define RESET 10
#define SD_CS BUILTIN_SDCARD
#define SD_CONFIG  SdioConfig(FIFO_SDIO)
ILI9341_t3 tft = ILI9341_t3(TFT_CS, TFT_DC, RESET);
SdFs sd;

void bmpDraw(FsFile &f);
uint16_t read16(FsFile &f);
uint32_t read32(FsFile &f);

void setup(void) {
  tft.begin();
  tft.setClock(60000000);
  tft.fillScreen(ILI9341_BLUE);
  tft.setRotation( 3 );

  Serial.begin(115200);
  tft.setTextColor(ILI9341_WHITE);
  tft.setTextSize(2);
  tft.println(F("Waiting for Arduino Serial Monitor..."));
  while (!Serial) {
    if (millis() > 1000) break;
  }

  Serial.print(F("Initializing SD card..."));
  tft.println(F("Init SD card..."));
  while (!sd.begin(SD_CONFIG)) {
    Serial.println(F("failed to access SD card!"));
    tft.println(F("failed to access SD card!"));
    delay(100);
  }
  Serial.println("OK!");
  // bmpDraw("datalog.txt", 0, 0);
  delay(1000);
}

void loop() {
  int bmpWidth, bmpHeight;   // W+H in pixels

  FsFile bmpFile = sd.open("METSUKEN.txt", FILE_READ);
  if (!bmpFile) {
    Serial.print(F("MATSUKEN not found"));
    return;
  }

  bmpHeight = read32(bmpFile);
  bmpWidth  = read32(bmpFile);

  for(int i = 0; i <= 6043; i++) {
    bmpDraw(bmpFile);
  }
  bmpFile.close();
}

//===========================================================
// Try Draw using writeRect
void bmpDraw(FsFile &f) {
  uint32_t startTime = millis();

  // 実際に描画する部分
  tft.beginSPITransaction(tft._clock);
  tft.setAddr(0, 0, 319, 239); //書く範囲指定
  tft.writecommand_cont(ILI9341_RAMWR);
  for(int y=0; y<240*320; y++){
    if(y==240*320-1){//最後はwritedata16_last
        tft.writedata16_last(read16(f)); 
        tft.endSPITransaction(); //描画終了
      }
    else tft.writedata16_cont(read16(f)); //ピクセル(x,y)描画
  }

  // while(millis()-startTime <= 49); //fps固定
  // かかった時間の表示
  Serial.print(F("Loaded in "));
  Serial.print(millis() - startTime);
  Serial.println(" ms");
}

uint16_t read16(FsFile &f) {
  uint16_t result;
  ((uint8_t *)&result)[0] = f.read(); // LSB
  ((uint8_t *)&result)[1] = f.read(); // MSB
  return result;
}

uint32_t read32(FsFile &f) {
  uint32_t result;
  ((uint8_t *)&result)[0] = f.read(); // LSB
  ((uint8_t *)&result)[1] = f.read();
  ((uint8_t *)&result)[2] = f.read();
  ((uint8_t *)&result)[3] = f.read(); // MSB
  return result;
}

Discussion