タイトル通り、何とか年内に完成と言える所まで漕ぎ着けました。日々少しつづとは言え、UNO R4 での動作確認 から GUI モックアップ作成 を経て約5ヶ月、長かったです 😮‍💨

これまでの検討経緯

まずはこの5ヶ月間に実施した、検討用リポジトリのまとめです。

  1. Arduino-UNO-R4/MLX90640
    MLX90640 センサボードの動作確認を行ったコードです。この時点ではまだ UNO R4 しか所有していませんでした。

  2. GFX ライブラリのベンチマーク
    新たに入手した XIAO ESP32-S3 で4つの GFX ライブラリのベンチマークを実施し、ターゲットを LovyanGFXTFT_eSPI の2つに絞り込みむこととしました。

  3. SD カードライブラリの動作検証
    各 GFX ライブラリと、Espressif 標準の SD カードライブラリSdFat との相互運用性を検証しました。この時の検討結果とは異なり、現在は SdFat の方が高速で、動画を 16 FPS で記録出来ています。

  4. Arduino-XIAO-ESP32/FreeRTOS
    ESP32 の2つのコアを同期、非同期で動かす実験です。検討の結果、センサ入力をコア1、レンダリングをコア0で、セマフォとメッセージキューを使って同期的に動かす仕様に決定しました。

  5. Arduino-XIAO-ESP32/MLX90640_GUI_mockup
    当初目論んだ LVGL を動かせなかったため、独自に作成した GUI のモックアップです。

  6. MLX90640Viewer
    Processing で作成した、MLX90640 専用の動画再生ビューワーです。

  7. Arduino-XIAO-ESP32/MLX90640
    今回の最終成果物であるサーモグラフィカメラのリポジトリです。

最終仕様のまとめ

若干のジャンクなノウハウを含め、改良点について書き留めたいと思います。

スプライトによるレンダリングの高速化

従来は drawPixel()fillRect() メソッドで直接ディスプレイのインスタンスに描画していましたが、一旦メモリ上に生成したスプライトに描画した後、pushSprite() で一気に転送するようにした所、描画時間を劇的に高速化(168msec → 48msec)できました。

スプライト適用前後のフレームレート
スプライト適用前後のフレームレート

さらに DMA 転送も効かせたかったのですが、以下の理由により断念しています。

LovyanGFX の DMA 転送について

ソースコードを完全に読み切れてはいませんが、initDMA() により、writePixels()writePixelsDMA()pushPixelsDMA()pushImage()pushImageDMA() および pushSprite() の DMA 転送が有効になるようです(まだ他にもあるカモですが…)。

ただし、ラングシップさんの「ESP32のヒープメモリ管理 その1」によれば、

DMA対応メモリは、SPIやI2Sなどにハードウエアを利用したDMA転送で使える領域のメモリです。 SPI接続のPSRAMはDMA転送で利用できないので除外されます。

とのことです。事実、LGFX_Sprite::setPsram(true) などでスプライトを PSRAM 上に生成した場合、push_sprite() の定義により、DMA が無効となっています。

void push_sprite(LovyanGFX* dst, int32_t x, int32_t y, uint32_t transp = pixelcopy_t::NON_TRANSP)
{
  pixelcopy_t p(_img, dst->getColorDepth(), getColorDepth(), dst->hasPalette(), _palette, transp);
  dst->pushImage(x, y, _panel_sprite._panel_width, _panel_sprite._panel_height, &p, _panel_sprite.getSpriteBuffer()->use_dma()); // DMA disable with use SPIRAM
}

今回、192×144 ピクセルのスプライトまでは主メモリ上に確保でき DMA が効きましたが、256×192 ピクセル(単純計算で 256×192×2 (RGB565) = 96KB)が確保できず「黒い画面」となりました。GUI 関連で相当数の静的変数を宣言しているためメモリ不足となったようです。そこで止むを得ず DMA は断念し、スプライトは PSRAM 上に確保することにしました。

ちなみに 192×144 ピクセルで initDMA() を無効化/有効化した時のレンダリング結果を以下に示します。僅か 2msec(26msec → 24msec)ほどですが、確かに効果はありました。

スプライトに対する DMA の効果
スプライトに対する DMA の効果

TFT_eSPI の DMA 転送について

どんな場合に TFT_eSPI::initDMA(true) が効くのか、LovyanGFX ほどソースコードを読み切れてはいませんが、わずかながら効果がありました(256×192 ピクセルの場合)。LovyanGFX と同様、DMA を有効にした場合、下記コードによりスプライトは PSRAM 上には生成されません。

#if defined (ESP32) && defined (CONFIG_SPIRAM_SUPPORT)
    if ( psramFound() && _psram_enable && !_tft->DMA_Enabled)
    {
      ptr8 = ( uint8_t*) ps_calloc(frames * w * h + frames, sizeof(uint16_t));
      //Serial.println("PSRAM");
    }
    else
#endif
    {
      ptr8 = ( uint8_t*) calloc(frames * w * h + frames, sizeof(uint16_t));
      //Serial.println("Normal RAM");
    }

逆に TFT_eSPI::initDMA(false) の場合には、TFT_eSPI コンストラクタ中の設定 によりスプライトが PSRAM 上に確保されます。

#if defined (ESP32) && defined (CONFIG_SPIRAM_SUPPORT)
  if (psramFound()) _psram_enable = true; // Enable the use of PSRAM (if available)
  else
#endif
  _psram_enable = false;

ただし CONFIG_SPIRAM_SUPPORT は、ESP-IDF の ビルド時に定義されるシンボル で、Arduino IDE でのコンパイル時には未定義となります(CONFIG_SPIRAM は参照可)。恐らく ESP-IDF の古いバージョンからの仕様変更に追従できていない TFT_eSPI のバグでしょう。

LavyanGFX とは違い、256×192 ピクセルのスプライトを主メモリ上に確保しても「黒い画面」にはなりませんが、やはりメモリ不足の懸念があるので DMA は有効化せず、User_Setup.h に以下を追加してスプライトを PSRAM 上に確保する設定としています。

// Allocate a memory area for Sprite
// https://github.com/Bodmer/TFT_eSPI/blob/master/Extensions/Sprite.cpp#L165-L172
#define CONFIG_SPIRAM_SUPPORT // originally defined as CONFIG_SPIRAM in esp-idf sdkconfig

本当に PSRAM では DMA が効かないのか?

ラングシップさんの情報を疑うワケではありませんが、出典を調べないと気が済まないタチなので…。特に ESP32 は派生がいくつかあるので、ESP32-S3 について調べてみました。出典は「ESP32-S3 Technical Reference Manual Version 1.6(2024-12-10版)」です。

「3. GDMA Controller (GDMA)」には以下の記述があります。

原文:

General Direct Memory Access (GDMA) is a feature that allows peripheral-to-memory, memory-to-peripheral, and memory-to-memory data transfer at a high speed. The CPU is not involved in the GDMA transfer, and therefore it becomes more efficient with less workload.

日本語訳:

GDMA (General Direct Memory Access) は、周辺機器からメモリ、メモリから周辺機器、 メモリからメモリへのデータ転送を高速に実行できる機能です。CPU は GDMA 転送に 関与しないため、作業負荷が軽減され、効率が向上します。

一方、無印 ESP32 の Technical Reference Manual Version 5.2(2024.08版)には、GDMA の記述は一切ありません。

また「3.4.9 Accessing External RAM」には、外部 RAM の特定アドレスで GDMA が効きそうな記述があります。

原文:

Any transmit and receive channels of GDMA can access 0x3C000000 ~ 0x3DFFFFFF in external RAM.

日本語訳:

GDMA の送信および受信チャネルはすべて、外部 RAM の 0x3C000000 ~ 0x3DFFFFFF にアクセスできます。

ESP32-S3 のメモリマップ
ESP32-S3 のメモリマップ

そこで ESP32-S3 のメモリマップ を観ると、0x3C0000000x3DFFFFFF は PSRAM 領域であることが分かります。

(このメモリマップは、Espressif 開発者ポータルの2024年8月20日付け記事「ESP32’s family Memory Map 101」に掲載されたマップとは、かなり異なっています)

次に LovyanGFX を対象に、以下のコードでスプライトを PSRAM 上に確保し、そのアドレスを調べてみます(諸々省略しています)。

LGFX lcd;
LGFX_Sprite lcd_sprite(&lcd);

if (psramInit()) {
  Serial.printf("\nThe PSRAM is correctly initialized.\n"));
} else {
  Serial.printf("\nPSRAM does not work.\n"));
}

lcd_sprite.setPsram(true);
lcd_sprite.createSprite(256, 192); // or createSprite(w, h, true);

Serial.printf("Sprite buffer adrs : 0x%X\n", lcd_sprite.getBuffer());
Serial.printf("Sprite buffer len  : %d\n",   lcd_sprite.bufferLength());

lcd_sprite.deleteSprite();

結果は次の通り、GDMA の対象領域に入っていることが分かりました。

Sprite buffer adrs : 0x3C08BC08
Sprite buffer len  : 98304

ということで、少なくとも ESP32-S3 には GDMA という機構があり、PSRAM でも DMA が効く可能性があることが分かりました。この件は、継続調査としたいと思います :v:

フレームレートとノイズ

MLX90640 のデータシート 「12.3. Noise performance and resolution」に掲載されたフレームレートとノイズの関係を示すグラフ(Ta = 25°C)によれば、高フレームレートほどノイズの影響を受け易く、バラツキの標準偏差(RMS 値)が大きくなることが分かります。

フレームレートとノイズの関係
フレームレートとノイズの関係

とかく高フレームレートに注目しがちですが、今回の主目的である「基板の温度分布測定」には低フレームレートによる観測が適しています。そこで対象に合わせてフレームレートを設定できるようにしました。実際の画像を見れば、その効果は一目瞭然と思います。

フレームレートの設定メニューと実際の画像
フレームレートの設定メニューと実際の画像

ヒートマップ

MLX60960 用ライブラリ Adafruit_MLX90640 の例題 には、紫 〜 赤まで虹色のヒートマップが定義されています。これはコレで境界がハッキリして分かり易いものの、温度の変化に対する明るさの変化が直感的ではありません。そこで Matplotlib のカラーマップから inferno(「灼熱地獄」と言うネーミングがお気に入りです)の RGB 値をサンプリングしました。

Matplotlib のカラーマップ
Matplotlib のカラーマップ
Inferno の RGB グラフ
Inferno の RGB グラフ

作成したRGB グラフを元に、カラーマップを任意の階調数で作れるよう、各値を多項式近似しました。B(青)をそれなりに再現するには6次以上が必要ですが、R(赤)、G(緑)と同じ3次多項式での近似としています。実際、2.4 インチ、16ビット(RGB565)のディスプレイでは区別できません。

温度画像データの記録と再現

Adafruit の記事 に刺激を受け、キャプチャ画面をビットマップに保存する機能に加え、連続画像を記録する機能を実装しました。記事では1フレームずつビットマップを保存していますが、32×24×4 (float) バイトの連続した生データを1つのファイルとして SD カードに保存する仕様としています。

また本体内にビットマップのサムネイル表示と動画再生の機能を実装し、さらに PC 上で再生可能なビューワー MLX90640Viewer も作成しました。

ビューワーのレンダリング部分(バイリニア補間+ヒートマップ)は本体から移植し、本体の動画再生部分はビューワーから逆移植しています 💫

ファイルエクスプローラー
ファイルエクスプローラー
MLX90640Viewer
MLX90640Viewer

ビューワーのプログラミングは基本 Java ですが、ESP32(あるいは大抵の Arduino ボード)のバイトオーダーはリトルエンディアンで、Java はビッグエンディアンなので変換が必要です。この変換は JPCERT の「FIO12-J. リトルエンディアン形式のデータを読み書きするメソッドを用意する」を参考にしました。

個体情報の確認

個体の識別情報
個体の識別情報

メモリの使用状況とか、ファイルエクスプローラーで使ってる便利な std::vectorstd::sort のサポート状況とかを知りたかったので組み込んでみました。

ハッキリ言ってどーでもよい機能なのですが、参考資料と共に一応紹介しておきますネ。

  • uxTaskGetStackHighWaterMark
    タスクの残りスタックサイズを得る FreeRTOS の関数。
  • Miscellaneous System APIs
    Espressif 公式の ESP-IDF プログラミングガイド。ソフトウェアによるリセット、MAC アドレスやメモリ関連情報の取得方法、チップや ESP-IDF のバージョン取得方法など、諸々。
  • ESP.h
    複雑な構成の ESP32 メモリ情報を簡単に取得するための便利 API 集。
  • Which version of c++ is currently supported
    Arduino フォーラムに投稿された、GNU C++ サポートバージョンの情報。
ESP32 個体情報を出力する参考コード
// 別ファイルで定義されたグローバル変数
extern Adafruit_MLX90640 mlx;
extern TaskHandle_t taskHandle[2];

// C++バージョンの情報
const struct {
  uint32_t ver;
  char*    std; 
} cpp[] = {
  {199711, "C++03"},
  {201103, "C++11"},
  {201402, "C++14"},
  {201703, "C++17"},
  {202002, "C++20"},
  {202302, "C++23"},
  {203000, "C++xx"},
};

char *cpp_ver = "";
for (int i = 0; i < sizeof(cpp) / sizeof(cpp[0]) - 1; i++) {
  if (cpp[i].ver <= __cplusplus && __cplusplus < cpp[i+1].ver) {
    cpp_ver = cpp[i].std;
    break;
  }
}

printf("MLX90640 S/N: %04X%04X%04X\n", mlx.serialNumber[0], mlx.serialNumber[1], mlx.serialNumber[2]);
printf("MCU model   : %s R%d\n", ESP.getChipModel(), ESP.getChipRevision());
printf("ESP-IDF ver : %d.%d.%d %s\n", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH, cpp_ver);
printf("Task 1 stack: %7d\n", uxTaskGetStackHighWaterMark(taskHandle[0]));
printf("Task 2 stack: %7d\n", uxTaskGetStackHighWaterMark(taskHandle[1]));
printf("Heap total  : %7d\n", ESP.getHeapSize());
printf("Heap lowest : %7d\n", ESP.getMinFreeHeap());
printf("PSRAM total : %7d\n", ESP.getPsramSize());
printf("PSRAM lowest: %7d\n", ESP.getMinFreePsram());
printf("Sketch free : %7d\n", ESP.getFreeSketchSpace());
printf("Sketch size : %7d\n", ESP.getSketchSize());
項目 内容
MCU model MCUのモデル記号、チップのリビジョン番号 ESP32-S3 R2
ESP-IDF ver ESP-IDFのバージョン番号 / C++バージョン番号 5.3.2 C++17
Task 1 stack タスク1スタックの 起動後最小サイズ 1 5000
Task 2 stack タスク2スタックの 起動後最小サイズ 1 5848
Heap total ヒープメモリの総サイズ 176140
Heap lowest ヒープメモリの 起動後最小サイズ 1 122996
PSRAM total PSRAMの総サイズ 8388608
PSRAM lowest PSRAMの 起動後最小サイズ 1 8242332
Sketch free スケッチ用の空きサイズ 3342336
Sketch size スケッチのサイズ 559328

ちょっと紛らわしいのですが、「C++バージョン番号」は、スケッチをコンパイルする時の xtensa 用 GNU コンパイラのバージョンです。ESP-IDF 自体は C++23 でビルドされています(出典:ESP-IDF Programming Guide / C++ Support)。

Espressif の ESP32 ボードパッケージ 3.1.0 では GNU コンパイラ 13.2.0 が使われていて、僕の Intel Mac では C++17(13.2.0 のデフォルト)でした。個体情報ではないので組み込む必要は全くないのですが、前バージョンの 3.0.7 が C++20 相当 😳(なぜ?)でしたので…

ちなみに他のバージョンが必要な場合(例えば -std=c++20)は platform.txt で設定可能です。ただし 13.2.0 の マニュアルには

Support is experimental, and could change in incompatible ways in future releases.
サポートは実験的なものであり、将来のリリースでは互換性のない方法で変更される可能性があります。

とあるので、ご注意を。

😎 お遊びデモ

新しいカメラを手にすると、色々と写したくなりますよね。我が家の愛犬とかご近所さんとか、撮影してみました。

まぁ、価格差を考えれば仕方のないことですが、サーモパイルアレイ の限界で、マイクロボロメータ には敵いませんネ :hugs:

さて…

発熱が激しいという RPi 5 を購入したので、どんな具合か見たいというのが元々の目的だったのですが、未だ箱に入ったまま放置です。ま、正月休みにゆっくりと組み上げます。

また、LVGL にもリベンジしたいですね。LovyanGFX で動作させることが目標なので、成功したら報告したいと思います。

来年も良い年になりますように :bamboo:


  1. 「起動後最小サイズ」とは、電源投入から参照時点まででアロケートされた最大のメモリサイズを総サイズから差し引いた値。 “watermark”(透かし)と呼ばれる定型パターンを元に算出していると思われます。  2 3 4