LovyanGFX入門 その8 画面キャプチャからのGIF作成

概要

前回はスプライトの中身を説明しました。今回は実際の使い方をと思ったのですが、画面キャプチャしたものをGIF画像に保存する方法を忘れないうちに書いておきたいと思います。

画面キャプチャとは?

対応しているかは接続しているLcdに依存するのですが、大抵のLcdはデータを読み出すことができます。つまりLcdに描画した内容を読み出して、画像ファイルに保存することが可能です。

ちょっと注意することがありますので、解説をしておきたいと思います。

公式スケッチ例でも画面キャプチャ例が登場しています。

データ取得方法

readPixel() RGB565取得

std::uint16_t lgfx::LGFXBase::readPixel(
    std::int32_t 	x,
    std::int32_t 	y 
)

X座標とY座標を指定して、RGB565形式で色を取得する関数です。一番基本的な関数ですが、画面キャプチャでは遅いので利用しません。

readPixelRGB() RGB888取得

RGBColor lgfx::LGFXBase::readPixelRGB(
	std::int32_t 	x,
	std::int32_t 	y 
)

RGB888で取得する場合の関数です。Lcdによってはlcd.setColorDepth(24)で動作するものがありますので、その場合はこの関数を利用すると色情報が失われません。

readRect() 範囲指定読み出し

void lgfx::LGFXBase::readRect(
	std::int32_t 	x,
	std::int32_t 	y,
	std::int32_t 	w,
	std::int32_t 	h,
	T * 	data 
)

範囲を指定してデータを取得する関数です。dataの型によって取得する色情報が変わります。uint16_t*の配列を渡すとRGB565になり、uint8_t*の配列を渡すとRGB332、uint8_t*の配列を渡すとRGB888になります。

この関数で画面全体を指定して読み出せばいいのですが、読みだしたデータを保存するメモリが足りないので分割して読み出す必要がありますので注意してください。

キャプチャ方法

LovyanGFXライブラリ単独の場合

画面キャプチャ用の関数は現在準備されていませんので、スケッチ例を参考に保存する関数をコピーしてきて使います。

先程紹介したスケッチ例になります。要点だけ説明します。

  do {
    SD.end();
    delay(1000);
    SD.begin(SDCARD_SS_PIN, SDCARD_SPI, 25000000);
  } while (!saveToSD_16bit());

setup()関数の中で、上の方にあるのは保存するための画像を描画しているところなので省略します。上記が実際に保存している部分です。

SDカードに保存しますので、念の為SD.end()でアクセスを終了させ、SD.begin()で初期化とSDカードのマウントをしています。SDCARD_SS_PINはM5Stack(BASIC, GRAY, GO, FIRE, Core2)はGPIO4が多いですが、ボードによってことなるので注意してください。とくにM5Stack ATOMなどの外部接続のSDカードは設定が異なります。

// グローバルで宣言
SPIClass SPI_EXT;
// SDをATOM(Lite, Matrix)に接続してSPI初期化。Echoには接続できません!
// 3V3  3V3
// GND  GND
// CLK  GPIO23
// MISO GPIO33
// MOSI GPIO19
// CS   GND
SPI_EXT.begin(23, 33, 19, -1);
// SDの初期化CSはGNDに直結しているので-1で利用しない
SD.begin(-1, SPI_EXT);

SPIバスを複数の機材で共有している場合にはCSで選択する必要があります。1台のみ接続している場合には通常GNDに落として常に選択されている状態にしますので、プログラムからは制御しません。

bool saveToSD_16bit()
{
  std::size_t dlen;
  bool result = false;
  File file = SD.open(filename, "w");
  if (file)
  {
    int width  = lcd.width();
    int height = lcd.height();
    int rowSize = (2 * width + 3) & ~ 3;
    lgfx::bitmap_header_t bmpheader;
    bmpheader.bfType = 0x4D42;
    bmpheader.bfSize = rowSize * height + sizeof(bmpheader);
    bmpheader.bfOffBits = sizeof(bmpheader);
    bmpheader.biSize = 40;
    bmpheader.biWidth = width;
    bmpheader.biHeight = height;
    bmpheader.biPlanes = 1;
    bmpheader.biBitCount = 16;
    bmpheader.biCompression = 3;
    file.write((std::uint8_t*)&bmpheader, sizeof(bmpheader));

次に実際に保存している部分を見てみます。この関数をそのままコピーすれは動きます。冒頭の部分はBMPファイルのヘッダ部分になります。SDでファイルをオープンしてから、ヘッダを書き込んでいます。

    std::uint8_t buffer[rowSize];
    memset(&buffer[rowSize - 4], 0, 4);
    for (int y = lcd.height() - 1; y >= 0; y--)
    {
      lcd.readRect(0, y, lcd.width(), 1, (lgfx::rgb565_t*)buffer);
      file.write(buffer, rowSize);
    }

ここがメインの処理です。画像を横1ライン単位で取得しています。bufferは画面の横幅分のメモリを確保してありますね。

BMPファイルの構造で注意しないといけないのは画面下から上に向かってデータを保存します。なのでfor文ではlcd.height() – 1から開始して、1ずつ減らしながら0までを取得しています。

M5Liteライブラリの場合

#include "M5Lite.h"
void setup(void)
{
  M5Lite.begin();
  M5Lite.Lcd.fillScreen(TFT_RED);
  M5Lite.Ex.screenshot(SD, "/red.bmp");
}
void loop(void)
{
  M5Lite.Ex.delay(1);
}

M5Stack(BASIC, GRAY, GO, FIRE, Core2)であればSDの初期化も自動で行いますので、上記のようにM5Lite.Ex.screenshot()関数を呼び出すだけで保存できます。ファイル名は/から書く必要があるので注意してください。

また、M5Lite.Ex.delay()を使うことでシリアルモニタから対話式のコマンドを送信することができます。SCREENSHOTコマンドを送信することで、任意のタイミングでBMPファイルのスクリーンショットを保存することが可能です。

ESP32-Chimera-Coreライブラリの場合

M5LiteライブラリはM5Stack社の複数のボードを同じようにコーディングするためのライブラリです。そのため公式ライブラリとは互換性が失われている場所があります。おもにM5StickCライブラリの利用感をベースに拡張しています。IMUなどはどのボードでも同じインターフェイスで、同じ座標系で利用することができます。

ESP32-Chimera-CoreはM5Stack社の公式ライブラリを組み合わせて、一つにしたようなライブラリです。公式ライブラリのソースを持ってきているため、互換性は高いですが一部拡張されています。互換性が高い変わりにボード別の差異は自分で判定をして作り分けをする必要があります。

#include <ESP32-Chimera-Core.h>
void setup() {
  M5.begin();
  M5.ScreenShot.init(&M5.Lcd, M5STACK_SD);
  M5.ScreenShot.begin();
  M5.Lcd.fillScreen(TFT_RED);
  M5.ScreenShot.snapBMP("/red.bmp");
}
void loop() {
}

M5.ScreenShotクラスが追加されており、初期化をすることで利用可能になります。

M5Liteの場合には独自拡張はM5Lite.Exクラス以下にまとめていますが、ESP32-Chimera-Coreの場合には混ざっているので公式ライブラリとの差異に注意してください。

M5LiteもESP32-Chimera-Coreも描画エンジンは同じLovyanGFXを利用しています。同じようなライブラリですが、ESP32-Chimera-CoreはLovyanGFXができる前から存在していたライブラリになります。そのためLovyanGFXのボード自動判定機能ではなく、プログラムする人が判断をする前提になっています。

M5LiteはLovyanGFXのボード自動判定機能を利用する前提で作られていますので、ボードごとの差異はライブラリの内部でできるだけ吸収するようにしています。その過程で公式ライブラリとは互換性が無くなっている部分ができていますので注意してください。

動画のキャプチャ

#include "M5Lite.h"
#include <vector>
#define LINE_COUNT 6
static std::vector<int> points[LINE_COUNT];
static int pointColors[] = { TFT_RED, TFT_GREEN, TFT_BLUE, TFT_CYAN, TFT_MAGENTA, TFT_YELLOW };
static int xoffset, yoffset, point_count;
int getBaseColor(int x, int y)
{
  return ((x ^ y) & 3 || ((x - xoffset) & 31 && y & 31) ? TFT_BLACK : ((!y || x == xoffset) ? TFT_WHITE : TFT_DARKGREEN));
}
void setup(void)
{
  M5Lite.begin();
  if (M5Lite.Lcd.width() < M5Lite.Lcd.height()) M5Lite.Lcd.setRotation(M5Lite.Lcd.getRotation() ^ 1);
  yoffset = M5Lite.Lcd.height() >> 1;
  xoffset = M5Lite.Lcd.width()  >> 1;
  point_count = M5Lite.Lcd.width() + 1;
  for (int i = 0; i < LINE_COUNT; i++)
  {
    points[i].resize(point_count);
  }
  M5Lite.Lcd.startWrite();
  M5Lite.Lcd.setAddrWindow(0, 0, M5Lite.Lcd.width(), M5Lite.Lcd.height());
  for (int y = 0; y < M5Lite.Lcd.height(); y++)
  {
    for (int x = 0; x < M5Lite.Lcd.width(); x++)
    {
      M5Lite.Lcd.writeColor(getBaseColor(x, y - yoffset), 1);
    }
  }
  M5Lite.Lcd.endWrite();
}
void loop(void)
{
  static int prev_sec;
  static int fps;
  ++fps;
  int sec = millis() / 1000;
  if (prev_sec != sec)
  {
    prev_sec = sec;
    M5Lite.Lcd.setCursor(0, 0);
//    M5Lite.Lcd.printf("fps:%03d", fps);
    fps = 0;
  }
  static int count;
  for (int i = 0; i < LINE_COUNT; i++)
  {
    points[i][count % point_count] = (sinf((float)count / (10 + 30 * i)) + sinf((float)count / (13 + 37 * i))) * (M5Lite.Lcd.height() >> 2);
  }
  ++count;
  M5Lite.Lcd.startWrite();
  int index1 = count % point_count;
  for (int x = 0; x < point_count - 1; x++)
  {
    int index0 = index1;
    index1 = (index0 + 1) % point_count;
    for (int i = 0; i < LINE_COUNT; i++)
    {
      int y = points[i][index0];
      if (y != points[i][index1])
      {
        M5Lite.Lcd.writePixel(x, y + yoffset, getBaseColor(x, y));
      }
    }
    for (int i = 0; i < LINE_COUNT; i++)
    {
      int y = points[i][index1];
      M5Lite.Lcd.writePixel(x, y + yoffset, pointColors[i]);
    }
  }
  M5Lite.Lcd.endWrite();
  M5Lite.Ex.delay(1);
  static int imgCount = 0;
  char filename[10];
  sprintf(filename, "/img_%04d", imgCount);
  if(imgCount<1000){
    imgCount++;
    M5Lite.Ex.screenshot(SD, String(filename) + String(".bmp"));
  }
}

これはLovyanGFXの作者であるらびやんさんが公開していたグラフ描画の作例を元に、1000枚の画面キャプチャをしたときのスケッチです。画面キャプチャしている関係でfps表記はおかしくなっているので削っていますが、M5Lite.Ex.screenshot()で1フレームごとに保存しているだけのコードです。

この作業によって1000枚のBMPファイルが生成されます。ちなみに1枚保存するのには2秒程度かかりますので、30分以上かかっています、、、

ちなみにreadRect()関数ではなく、readPixel()関数を利用すると4秒程度と倍の時間がかかりました。やはりピクセルの読み出しは重いのである程度の塊で取得したほうが良さそうです。

GIF画像に変換(失敗)

変換には上記のWindows用のアプリを利用させていただきました。

画像が入っているフォルダをドロップして、大きさは変更しないので「入力画像サイズを維持」にチェックを入れます。左上にある出力FPSが速度になりますので適当に変更してから変換開始を押します。

※数日前はうごいたのですが、今動かすと期限切れで変換できません、、、

GIF画像に変換(ffmpeg)

変換に失敗したのでffmpegを使ってみたいと思います。GIMPなどでもできるのですが、1枚開くのに1秒ぐらい時間がかかったのでffmpegでサクッと変換したいと思います。

ffmpeg -r 30 -i C:\Temp\img\img_%04d.bmp -pix_fmt rgb24 -f gif output_30.gif

こんな感じで変換できました。-rのあとが1秒間に何枚描画するかになります。

できたのがこのアニメーションGIFファイルになります。グラグが動いていますでしょうか?

やっぱり動いたほうが絵力がありますよね。ただし、1000枚は多すぎます。保存する画像を間引いて、再生枚数を減らしたほうがいいかと思います。

スマホで動画を録画したほうがかんたんなんですが、やっぱりクオリティー的には画面キャプチャの方が上だと思います。

まとめ

画面キャプチャしてアニメーションGIFにしたい人がどれほどいるかはかなり謎です。ただ画面キャプチャ自体はかんたんに利用できて、結構便利なので使ってみてください。

続編

コメント

  1. chrmlinux より:

    お疲れ様でございます
    ESPDisplayぽいのつくりましたー
    https://qiita.com/chrmlinux/items/c66ca9ff5abcc9560902