作りたい機能と要件の再整理

前々回 は UNO R4 で動作確認を、続く 前回 は新たに XIAO(ESP32S3) を手配して実用的なカメラの製作を進めてきましたが、完成に向けてソフトウェア開発を推し進めるため、漠然としていた作りたい機能の目標を再整理しました。

  • 温度画像を擬似カラーで表示する機能、特定ポイントの温度を表示する機能
  • 測定の温度範囲や表示の解像度など、各種設定値を変更し保存するメニュー機能
  • 温度画像や擬似カラー画像を SD カードにファイルとして保存し、また表示する機能
XIAO、MLX90640、LCDの配線図
XIAO、MLX90640、LCDの配線図

一方これまで Cirkit Designer配線図 を描きましたが、Frizing も含め、この手のツールで描く配線図は、苦労の割にあまり役に立たない事が最近になって分かってきました。

例えば「XIAO、MLX90640、LCDの配線図」を見ても、どのようなソフトウェアを実装すべきか、すぐにはピンときません。配線の情報しか表現していないので、当たり前ですね :stuck_out_tongue_winking_eye:

サーモグラフィカメラのブロック図
サーモグラフィカメラのブロック図

ということで今回、「サーモグラフィカメラのブロック図」を改めて描いてみました。このブロック図を元に、I2C 通信、SPI 通信、LCD やタッチスクリーン、SD カードといったデバイス毎の要件を、次のように設定しました。

  1. I2C 通信
    滑らかで遅れの少ない表示となるよう、高速かつ高フレームレートで温度画像を取り込む。
  2. SPI 通信
    SPI を共有する LCD、タッチスクリーン、SD カードの全てが機能するよう SPI を構成する。
  3. LCD
    擬似カラー化した温度画像や色々な付帯情報を、できるだけ高解像度で描画する。
  4. タッチスクリーン
    特定ポイントの温度表示や設定のためのメニュー表示など、タッチをトリガに様々な機能を起動可能にする。
  5. SD カード
    LCD に表示された温度画像や各種情報を画像ファイルとして保存したり、保存された画像を読み出して LCD に表示できる様にする。

順を追って、こららの実装への落とし込みを披露していきたいと思います。

実装への落とし込み

MLX90640 〜 XIAO(ESP32S3)の I2C 通信仕様

I2C バス速度設定レジスタ仕様
I2C バスクロック設定レジスタ仕様

MLX90640 のデータシート によると、I2C のバスクロックは Fm+ (Fast-mode Plus) がデフォルトなので、1Mbps での通信が可能です。

一方マスター側の XIAO では、ESP32-S3 Series Datasheet「4.2.1.2 I2C Interface」に「800Kbps まで」との記述があり、また ESP32-S3 Technical Reference Manual に至っては、その記述すらありません。

• Standard mode (100 kbit/s)
• Fast mode (400 kbit/s)
Up to 800 kbit/s (constrained by SCL and SDA pull-up strength)
• 7-bit and 10-bit addressing mode
• Double addressing mode (slave addressing and slave register addressing)

マスター側は以下のコードで I2C バスクロックを設定でき、Wire.getClock()公式リファレンス には載っていない関数です)で調べてみると、1Mbps が返ってきます。

  // I2C bus clock for MLX90640
  // Note: ESP32S3 supports up to 800 MHz
  Wire.setClock(1000000); // 400 KHz (Sm) or 1 MHz (Fm+)
  Serial.println(Wire.getClock()); // 1000000
I2C SCLの観測
I2C SCLの観測

ところがオシロスコープで SCL を観たところ、リファレンス通り 800Kbps 程度しか出ていませんし、立ち上がりも相当に緩やかでした。ただ温度画像は取り込めているので、とりあえずはこの設定のままスルーしたいと思います(オィ :flushed: :point_right: スルーしちゃダメでした!

1KΩのプルアップ抵抗あり
1KΩのプルアップ抵抗あり
MLX90640 回路図
MLX90640 回路図

MLX90640 のブレイクアウトボード上に付いているとの勘違い からプルアップ抵抗を外付けしていませんでしたが、Arduino フォーラムで、ESP32 のマルチコア/マルチタスクについて質問した際、貼り付けたオシロスコープ画像に対し、「プルアップ抵抗、付いてないよね。これを専門用語で “酷い” って言うんだよ」と指摘されちゃいました。

気せずしてシロートっぷりが露呈しちゃったワケですが、回路図で推奨されている 1KΩ を追加したところ、波形が改善されました!フォーラムにも 事後報告 しています :sweat:

やっぱり「オカシイ」と感じたところは、動いているからと言ってもスルーしちゃダメって事ですね!

NXP の I2C バス仕様書 によると、Fm+ は「1Mbps まで」とのことなので、800Kbps でも大丈夫そうです。

I2C-bus specification and user manual
  • Bidirectional bus:
    • Standard-mode (Sm), with a bit rate up to 100 kbit/s
    • Fast-mode (Fm), with a bit rate up to 400 kbit/s
    • Fast-mode Plus (Fm+), with a bit rate up to 1 Mbit/s
    • High-speed mode (Hs-mode), with a bit rate up to 3.4 Mbit/s.
  • Unidirectional bus:
    • Ultra Fast-mode (UFm), with a bit rate up to 5 Mbit/s

また通信仕様ではありませんが、Adafruit_MLX90640::SetRefreshRate() で MLX90640 のサンプリング周波数を 0.5Hz 〜 64Hz の範囲で設定できます。実際は2回のサンプリングで1枚の画像を生成しているので、表示のフレームレートは設定した周波数の半分です。

色々と実験してみると、MLX90640 は「設定したサンプリング周波数 ≒ 全体のフレームレート(の2倍)」となるように振る舞うことが分かりました。この関係がズレると、ブロックノイズが載ったり全く取得できない事象が観測されます。また周波数を上げると露光時間が短くなるので、ノイズが載り易くなります。

このことから、全体のフレームレートによって自ずと取りうるサンプリング周波数の最大値が決まるため、次に紹介する画像処理の高速化がキーとなることが分かりました :+1:

XIAO(ESP32S3)の画像処理概要

温度画像を取り込んだ後の処理として、次の4つを想定しました。今回で全て実装できたワケではありませんが、考え方だけでも披露したいと思います。

  graph LR
  A["ノイズ除去"] --> B["高解像度化"] --> C["カラー画像生成"] --> D["LCD 表示"]

ノイズ除去

これはまだ未実装です。処理時間に余裕があれば、温度画像にガウシアンフィルタかメディアンフィルタを適用してみたいと考えています。今後の課題ですね。

高解像度化 - バイリニア補間のアルゴリズムと計算方法

動作確認編 では UNO R4 を使い、単純に1画素を7×7画素に拡大し 1.3” ディスプレイに表示してました。今回はより滑らかな画像を生成するため、もう少しマシな画素補間アルゴリズムで擬似的に高解像度化しました。

代表的な画素補間のアルゴリズムにバイリニア補間とバイキュービック補間があります。後者は Panasonic 製 赤外線アレイセンサ Grid-EYE 向けに、少し計算を簡略化したプログラムが Adafruit_AMG88xx に載っています。が、2.4” ディスプレイ向けに解像度を上げると実用的な処理時間に収まらず、また思ったほど綺麗な画像にはなりませんでした。

そこでより計算コストの小さいバイリニア補間を実装したところ、まずまずの結果が得られたので、そのアルゴリズムを簡単に紹介します。

以下の図は、画素 I(x,y)I(x,y)n×nn \times n に拡大する際の計算原理を示しています。

バイリニア補間の原理図

まず、2つの画素 I(x,y)I(x,y)I(x+1,y)I(x+1,y) からの距離に応じた比例配分により、矢印方向に B の画素値を計算します。

B=(1dx)I(x,y)+dxI(x+1,y)\begin{align} B &= (1-dx) \cdot I(x,y) + dx \cdot I(x+1,y) \end{align}

同様に、画素 I(x,y+1)I(x,y+1)I(x+1,y+1)I(x+1,y+1) から矢印方向に C の画素値を計算します。

C=(1dx)I(x,y+1)+dxI(x+1,y+1)\begin{align} C &= (1-dx) \cdot I(x,y+1) + dx \cdot I(x+1,y+1) \end{align}

最後は縦方向に B と C からの距離に応じた比例配分で A の画素値を計算します。

A=(1dy)B+dyC=(1dydy)(BC)\begin{align} A = (1-dy) \cdot B + dy \cdot C = \begin{pmatrix} 1-dy & dy \end{pmatrix} \begin{pmatrix} B\\ C \end{pmatrix} \end{align}

(1)、(2)、(3) をまとめると、A の画素値は次の行列式で求めることができます。

A=(1dydy)(I(x,y)I(x+1,y)I(x,y+1)I(x+1,y+1))(1dxdx)\begin{align} A = \begin{pmatrix} 1-dy & dy \end{pmatrix} \begin{pmatrix} I(x,y) & I(x+1,y)\\ I(x,y+1) & I(x+1,y+1) \end{pmatrix} \begin{pmatrix} 1-dx\\ dx \end{pmatrix} \end{align}

プログラムは GitHub の interpolation.cpp に上げたので、興味があれば参照してみて下さい。

演算の回数と方向について

(4) の行列式を全て展開すると次の様になりますが、行列式のまま計算するより乗算回数が増えてしまいます。

A=(1dx)(1dy)I(x,y)+dx(1dy)I(x+1,y)+(1dx)dyI(x,y+1)   +dxdyI(x+1,y+1)\begin{equation} \begin{split} A &= (1-dx) \cdot (1-dy) \cdot I(x,y) + dx \cdot (1-dy) \cdot I(x+1,y)\\ & + \, (1-dx) \cdot dy \cdot I(x,y+1) \ \; \, + dx \cdot dy \cdot I(x+1,y+1) \end{split} \end{equation}

また説明では、横方向に計算してから縦方向の計算を行いましたが、縦横を逆転させても同じ結果が得られます。これは (4) において、行列の乗算は順序を変えても結果が同じになることと等価です。

カラー画像生成

HSV 色空間
HSV色空間 - Wikipedia

現時点は Adafruit_MLX90640 の例題 にある 256 色のカラーテーブルを使っています。おそらくこのテーブルは、HSV 色空間 の S(彩度)と V(明度)を 100% に設定し、H(色相)を 0°〜 360° まで 256 色分をサンプリングして作られたものと思います(Wikipedia の図で、外側の円の部分)。

CIE 1931 xy 色空間
CIE 1931 色空間 - Wikipedia

次ステップでは、例えば CIE 1931 色空間 上を辿るスペクトル軌跡で独自のカラープロファイルを作るなど、16ビット(RGB565)で 65536 色を出せる LCD の能力を活かしたカラー変換を検討したいと思います。

LCD 表示

このパートでは、以下の4つの中から最も高速に描画できるグラフィック・ライブラリを選択するだけです。

そこで 描画速度のベンチマーク を実施したところ、LovyanGFX の圧勝となりました。

描画速度のベンチマーク結果
ベンチマーク Adafruit_GFX Arduino_GFX Lovyan_GFX TFT_eSPI
Screen fill 115,519 100,071 81,683 83,406
Text 109,677 15,463 18,627 23.982
Pixels 1,790,682 904,524 390,413 1,089,390
Lines 1,233,294 435,673 255,570 310,244
Horiz/Vert Lines 11,798 9,245 7,159 8,479
Rectangles-filled 240,282 208,162 169,746 173,568
Rectangles 8,106 6,203 4,738 5,450
Triangles-filled 106,388 80,118 61,673 68,413
Triangles 67,645 24,438 14,900 18,332
Circles-filled 70,073 40,776 28,548 38,811
Circles 138,015 42,721 23,124 29,515
Arcs-filled N/A 31,769 20,193 N/A
Arcs N/A 72,927 54,902 N/A
Rounded rects-fill 248,323 214,102 170,552 177,262
Rounded rects 46,158 19,740 9,872 15,184

また各ライブラリには、SPI バスを占有して一連のコマンドを実行する startWrite()endWrite() が提供されています。これを試したところ、LovyanGFX と TFT_eSPI では劇的な効果がありました。一方なぜか Adafruit_GFX と Arduino_GFX では画面の更新が止まってしまいました。残念ですが、この問題の掘り下げは一旦保留にしています。

ただし、この段階ではまだ SPI を共有するタッチスクリーンや SD カードの動作が未確認でしたので、全てのライブラリで動作するようコードを構成しています。

TFT_eSPI と ESP32S3 の相性問題と対策について

TFT_eSPI は ESP32S3 との相性が悪く、TFT_eSPI::init() で以下のメッセージを吐き出して再起動するという問題に遭遇しました。

TFT_eSPI library test!
Guru Meditation Error: Core  1 panic'ed (StoreProhibited). Exception was unhandled.

Core  1 register dump:
PC      : 0x420041cc  PS      : 0x00060430  A0      : 0x82004963  A1      : 0x3fcebd60  
A2      : 0x3fc94b1c  A3      : 0x00000000  A4      : 0x00000008  A5      : 0x00000009  
A6      : 0x000000ff  A7      : 0x00000001  A8      : 0x00000010  A9      : 0x08000000  
A10     : 0x3fc94c40  A11     : 0x019bfcc0  A12     : 0x00000301  A13     : 0x00000000  
A14     : 0x00000031  A15     : 0x3fc93b9c  SAR     : 0x00000002  EXCCAUSE: 0x0000001d  
EXCVADDR: 0x00000010  LBEG    : 0x40056f08  LEND    : 0x40056f12  LCOUNT  : 0x00000000  

Backtrace: 0x420041c9:0x3fcebd60 0x42004960:0x3fcebd90 0x42002335:0x3fcebdc0 0x4200b24d:0x3fcebde0 0x4037d136:0x3fcebe00

ELF file SHA256: 42fe0e0edde69bd7

Rebooting...
ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0xc (RTC_SW_CPU_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
Saved PC:0x40378c3a
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fce3818,len:0x109c
load:0x403c9700,len:0x4
load:0x403c9704,len:0xb50
load:0x403cc700,len:0x2fd0
entry 0x403c98ac

この問題は Issues にも数多く挙がっていています。

SPI 周りの定義に不整合があるらしく、Espressif の ESP32 用ボードパッケージをバージョン 2.0.14 に戻すか、User_Setup.hUSE_HSPI_PORTUSE_FSPI_PORT を有効にするのが当面の対策の様です。僕の環境では、前者では解決せず、以下のように後者で対応しています。

// The ESP32 has 2 free SPI ports i.e. VSPI and HSPI, the VSPI is the default.
// If the VSPI port is in use and pins are not accessible (e.g. TTGO T-Beam)
// then uncomment the following line:
#define USE_HSPI_PORT // or USE_FSPI_PORT

タッチスクリーンと SD カード

今回、スクリーンにタッチすると LCD 画面をキャプチャし、SD カードに保存する機能までが実装出来ました。が、幾つかトラブルがあったので、誰かのお役に立てることを願い、順を追って情報共有します。

SD カードに対する基本機能の確認

まずは「ディレクトリの作成・閲覧・削除」や「ファイルの作成・追加・削除」といった、基本機能の確認を実施しました。

ESP32 では、Arduino 標準の SD ライブラリの代わりに、espressif/arduino-esp32/libraries/SD が標準となっています。今回は同ライブラリの 例題スケッチ を元に、SdFat を加えた 評価用スケッチ を作成し確認しました。

GFX ライブラリ タッチライブラリ ESP32 標準の SD SdFat SHARED_SPI SdFat DEDICATED_SPI
Adafruit_GFX XPT2046_Touchscreen OK OK (*1) NG
Arduino_GFX XPT2046_Touchscreen OK OK (*1) NG
LovyanGFX <– OK (*2) NG NG
TFT_eSPI <– OK (*3) NG NG
(*1)、(*2)、(*3) の詳細

まず SdFat の (*1) ですが、SPI をタッチスクリーンと共有するためには SHARED_SPI の設定が必要になります。ただし、ESP32 標準に比べて処理時間が数倍に伸びてしまいました。当然 DEDICATED_SPI にするとタッチ機能が使えなくなります。よって SdFat を選択肢から外しました。

次に LovyanGFX の (*2) ですが、SD カードと SPI を共有しなければ、ホストの指定は SPI2_HOST でも SPI3_HOST でも動作します。しかし SD カードは sdspi_host.h#include <driver/sdspi_host.h> の追加で参照可能)で次の様に定義されているため、他デバイスと共有するには SPI2_HOST でなければなりません。

#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2
#define SDSPI_DEFAULT_HOST HSPI_HOST
#define SDSPI_DEFAULT_DMA  SDSPI_DEFAULT_HOST
#else
#define SDSPI_DEFAULT_HOST SPI2_HOST
#define SDSPI_DEFAULT_DMA  SPI_DMA_CH_AUTO
#endif

(*3)(*2) とは逆のパターンで、TFT_eSPI 内部で保持している SPI のインスタンスを TFT_eSPI::getSPIinstance() で取得し、SPI.begin() に渡す必要がありました。

ESP32 は C2、C3、C6、S2、S3、H2 など派生が多数あり、S3 に至ってはボードによってフラッシュメモリが Quad SPI(100MHz)と Octal SPI(200MHz)の2タイプ あり、よくよく注意しないとハマってしまいます。

スクリーンキャプチャ機能の確認

スクリーンキャプチャが機能するには、LCD のドライバ IC を通して各画素値を取得する必要があります。以下は、フォーマットの簡単な ビットマップ画像 を保存した時の結果です。

GFX ライブラリ 画素値を取得する関数 SD カードへの保存
Adafruit_GFX Adafruit_GFX::spiRead() 画素値の読み出しに失敗
Arduino_GFX なし 不可
LovyanGFX LGFX::readPixel() OK
TFT_eSPI TFT_eSPI::readPixel() ファイルの保存に失敗

Adafruit_GFX の場合、Arduino フォーラムの記事 を参考に画素値の読み出し関数を組み込みましたが、全て 0 が返り正しく機能しませんでした。

また Arduino_GFX にはそもそも画素値を読み出す関数が提供されていません。

TFT_eSPI については LovyanGFX と全く同じコードで、画素値は読み出せているのですが、なぜかファイル保存に失敗してしまいます。

ということで、要件を満たすには LovyanGFX がベスト! ということにして、他の不具合は深追いせず放置することにしました!メデタシ、メデタシ :stuck_out_tongue_winking_eye:

マルチコアによるマルチタスク化

ESP32 にはコアが2つ載っているので、これを利用しない手はありません。

そこでスループットの向上を目的に温度画像をダブルバッファ化し、MLX90640 から取り込む 入力タスク をコア1に、取り込んだ温度画像を LCD に表示する 出力タスク をコア0に配置してみました。

マルチコア/マルチタスクのタイミングチャート
マルチコア/マルチタスクのタイミングチャート

この場合、排他制御すべき共有資源をダブルバッファの「バンク ID」とし、メッセージキューとセマフォを使い、次のようなプロトコルでタスク間の同期を図りました。

  • 入力タスク は、温度画像を取り込んだ先のバンク ID をメッセージキューに入れて送信し、セマフォのリリースを待ちます。
  • 出力タスク は、メッセージを受け取ったらすぐにセマフォをリリースし、キューに格納されたバンク ID のバッファにアクセスを開始します。
  • セマフォを獲得した 入力タスク はバンク ID を更新し、出力タスク がアクセスしていない方のバンクに次の温度画像を取り込みます。

GitHub に サンプルスケッチ を上げたので、興味があれば覗いてみて下さい。

💮 ここまでの成果

画素補間の拡大率ごとに適切なサンプリング周波数を設定した時のスクリーンキャプチャ画像を添付します :v:

右側の数字は、上からセンサ温度 [℃]、フレームレート [Hz]、入力タスクの処理時間 [msec]、出力タスクの処理時間 [msec] です。出力タスクの処理時間は、入力タスクの裏に隠れているので、まだ少し余裕がありますネ。

画素補間あり(1画素→8画素)
画素補間あり(1画素→8画素)
画素補間あり(1画素→4画素)
画素補間あり(1画素→4画素)
画素補間あり(1画素→2画素)
画素補間あり(1画素→2画素)
画素補間なし
画素補間なし

ふぅ…

前回の記事 を上げてから1ヶ月…。幾つか自分のポカミスでうまく動かないトラブルがあり、ライブラリの開発元やフォーラムに相談した挙句に自己レス解決するという、何とも情けない日々を過ごしてきました 😮‍💨

恥ずかしながら、そのいくつかを紹介します :relieved:

…さて、気を取り直し、最後の仕上げとしてメニュー機能の製作に取り掛かろうと思います。lvgl とかが使えたら、挑戦してみたいですネ :skull: