2023年2月6日月曜日

ESP32でAVケーブル(RCA、NTSC)を介して画面表示


背景

ESP32向けの便利画面制御ライブラリLovyanGFXを作っているらびやんさんが下記の呟きをしていました。


手持ちのfull HDモニタがRCAケーブルの入力を受け付けていたので、LovyanGFXで出力するとどうなるか試してみました。

使ったもの

  • ESP32開発ボード(C3,S2,S3ではないもの)
    NTSCで扱える最大解像度(720x480)を使う場合はPSRAMが必要なので、WROVERを使うのが良いです。
    ESP32-DevKitC-VE ESP32-WROVER-E開発ボード
    扱いたい大きさが最大解像度の半分(360x240)ほどなら、WROOMなどSPRAM無し版で良いです。
    ESP32-DevKitC-32E
    注意点として、自分が試した時はESP32の回路バージョンがv0だと出力が安定しなかったので古いESP32の利用は避けるのが良いです。
  • ブレッドボード
    ESP32の信号線を引き出します。
  • ジャンパワイヤ
    信号線を引き回します。
  • RCAケーブル
    AVケーブルとも呼ばれます。
    amazonだと1000円弱で買えます。
    近所のハードオフのジャンク箱に入っていたものを買ってきました。
  • ワイヤストリッパー
    ケーブル剥きに使いました。
  • 太いケーブルをブレッドボードに引き込む基板
    被覆を剥いたRCAケーブルをブレッドボードに引き込むのに利用しました。
  • ESP32にプログラムを書き込むPC
    platformio + vscodeの環境を利用しました。
  • モニタ
    今回利用したのは下記のモニタです。
  • ものさし
    縦横比の実測に利用しました。

モニタの入力設定をAVに変更

有効な信号に自動で設定変更してくれるモニタなら設定不要ですが、今回利用したモニタは手動で信号の選択が必要でした。
気にかけないとHDMI入力設定でAV信号の描画が出来ない時間を過ごします。



回路作成

ケーブルを剥いて繋ぎます。
利用したケーブルは同軸ケーブルで、外側がGND(RCAコネクタの外側)、中心が信号線(RCAコネクタの心)に繋がっていました。
ということで、ESP32のGPIO26をケーブルの心に、GNDをケーブルの外側に接続します。


購入したケーブルは黄色と白が束になっていますが、黄色い方だけ使います。


ESP32に接続した黄色いケーブルをモニタに繋ぎます。


platformioのプロジェクト準備

PSRAMを使わない場合

ESP32向けの開発設定でLovyanGFXを依存ライブラリに指定します。
platformio.ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
LovyanGFX

PSRAMを使う場合

PSRAMを使わない場合の設定にPSRAM有効化のビルドフラグを追加します。
platformio.ini
[env:esp32dev-with-psram]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
LovyanGFX
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue

外枠、最小文字、縦横比の確認プログラムを準備

下記の説明ページのプログラムを参考にしつつ、外枠、最小文字、縦横比が分かる描画を行うプログラムを作成しました。

LovyanGFX lgfx::Panel_CVBS 使い方

外枠の位置と最小の文字の大きさを把握するためサンプルプログラムに下記の処理を加えています。
  • 外側から1単位ずつ赤、緑、青で枠を描画
  • 左上から右下にかけて緑色の線を描画
  • 中央に「hello at 経過時間」を最小の文字の大きさで表示
  • 中央左下に描画高さの1/3の紫色の1:1の四角を描画

200行近くある長めのコードですが全体像を載せ、その後は変更箇所の差分を記述します。
説明ページのプログラムとの違いは、setup関数後半、loop関数全体、インデントやコメントです。
#include <Arduino.h>

#include <LovyanGFX.hpp>

class LGFX : public lgfx::LGFX_Device {
public:

lgfx::Panel_CVBS _panel_instance;

LGFX(void) {
{ // 表示パネル制御の設定を行います。
auto cfg = _panel_instance.config(); // 表示パネル設定用の構造体を取得します。

cfg.memory_width = 240; // 出力解像度 幅
cfg.memory_height = 160; // 出力解像度 高さ
cfg.panel_width = 208; // 実際に使用する幅 (memory_width と同値か小さい値を設定する)
cfg.panel_height = 128; // 実際に使用する高さ (memory_heightと同値か小さい値を設定する)
cfg.offset_x = 16; // 表示位置を右にずらす量 (初期値 0)
cfg.offset_y = 16; // 表示位置を下にずらす量 (初期値 0)

_panel_instance.config(cfg);
}

{
auto cfg = _panel_instance.config_detail();

// 出力信号の種類を設定;
// cfg.signal_type = cfg.signal_type_t::NTSC;
cfg.signal_type = cfg.signal_type_t::NTSC_J;
// cfg.signal_type = cfg.signal_type_t::PAL;
// cfg.signal_type = cfg.signal_type_t::PAL_M;
// cfg.signal_type = cfg.signal_type_t::PAL_N;

// 出力先のGPIO番号を設定;
cfg.pin_dac = 26; // DACを使用するため、 25 または 26 のみが選択できます;

// PSRAMメモリ割当の設定;
cfg.use_psram = 0; // 0=PSRAM不使用 / 1=PSRAMとSRAMを半々使用 / 2=全部PSRAM使用;

// 出力信号の振幅の強さを設定;
cfg.output_level = 128; // 初期値128
// ※ GPIOに保護抵抗が付いている等の理由で信号が減衰する場合は数値を上げる。;
// ※ M5StackCore2 はGPIOに保護抵抗が付いているため 200 を推奨。;

// 彩度信号の振幅の強さを設定;
cfg.chroma_level = 128; // 初期値128
// 数値を下げると彩度が下がり、0で白黒になります。数値を上げると彩度が上がります。;

_panel_instance.config_detail(cfg);
}

setPanel(&_panel_instance);
}
};


LGFX gfx;

void setup(void) {
gfx.init();

for (int x = 0; x < gfx.width(); ++x) {
int v = x * 256 / gfx.width();
gfx.fillRect(x, 0 * gfx.height() >> 3, 7, gfx.height() >> 3, gfx.color888(v, v, v));
gfx.fillRect(x, 1 * gfx.height() >> 3, 7, gfx.height() >> 3, gfx.color888(v, 0 ,0));
gfx.fillRect(x, 2 * gfx.height() >> 3, 7, gfx.height() >> 3, gfx.color888(0, v, 0));
gfx.fillRect(x, 3 * gfx.height() >> 3, 7, gfx.height() >> 3, gfx.color888(0, 0, v));
}
delay(1000);
gfx.drawLine(0,0, gfx.width() - 1, gfx.height() - 1, TFT_GREEN);
gfx.drawRect(0,0, gfx.width(), gfx.height(), TFT_RED);
gfx.drawRect(1,1, gfx.width() - 2, gfx.height() - 2, TFT_GREEN);
gfx.drawRect(2,2, gfx.width() - 4, gfx.height() - 4, TFT_BLUE);
gfx.fillRect(gfx.width()/2, (gfx.height() >> 3) * 4, gfx.height() / 3, gfx.height() / 3, TFT_PURPLE);
}

void loop(void) {
gfx.setCursor(20, (gfx.height() >> 3) * 4);
gfx.setTextSize(0);
gfx.println("hello at " + String(millis()));
}

書き込んで動作に成功すると下記のような図が表示されます。


ESP32の信号線をモニタに繋げてプログラムを書き込んだら画像が表示されるなんて、ありがたや、素晴らしい。

縦横の比率は横が52.5mm、縦が45mmで、縦:横 = 1.167と横長でした。



ntscの最大解像度で描画

先程のプログラムの設定をこのようにして最大限に描画してみます。
      cfg.memory_width  = 720; // 出力解像度 幅
cfg.memory_height = 480; // 出力解像度 高さ
cfg.panel_width = 720; // 実際に使用する幅 (memory_width と同値か小さい値を設定する)
cfg.panel_height = 480; // 実際に使用する高さ (memory_heightと同値か小さい値を設定する)
cfg.offset_x = 0; // 表示位置を右にずらす量 (初期値 0)
cfg.offset_y = 0; // 表示位置を下にずらす量 (初期値 0)
最大限の描画にはESP32内臓のメモリでは不足するため、PSRAMのマイコンと環境設定を利用した上で、下記の設定を1か2にします。
PSRAMが無いマイコンでもビルドして書き込みはできますが、何も出力されません。
      // PSRAMメモリ割当の設定;
cfg.use_psram = 2; // 0=PSRAM不使用 / 1=PSRAMとSRAMを半々使用 / 2=全部PSRAM使用;

プログラムを書き込んで描画させると下記の表示になりました。


紫色の四角の大きさを図ると、横は67mmと、縦は58mm、縦:横 =  1.155と横長でした。



今回のモニタでの縦横比1:1の最大 720x418

縦横比が1:1になる縦横の描画幅を探ったところ720x418でした。
探った値とその時の四角の縦横の長さはこちらです。

不思議なことに縦のピクセル描画幅を変えても、縦の実測値は変わらず横の実測値が変化しました。
描画横幅px描画縦幅px四角の縦長さmm四角の横長さmm
74039054.558
74040055.558
7404105758
74041557.558
7404185858
74042058.558
7404806758

ということで、720x418で正方形を期待通りに正方形で描画できました。
      cfg.memory_width  = 720; // 出力解像度 幅
cfg.memory_height = 418; // 出力解像度 高さ
cfg.panel_width = 720; // 実際に使用する幅 (memory_width と同値か小さい値を設定する)
cfg.panel_height = 418; // 実際に使用する高さ (memory_heightと同値か小さい値を設定する)
cfg.offset_x = 0; // 表示位置を右にずらす量 (初期値 0)
cfg.offset_y = 0; // 表示位置を下にずらす量 (初期値 0)


縦横比を維持した上で余白の過不足を無くす 700x401

外枠の描画が画面の外で行われて表示されないので、それが描画される画面サイズとオフセット値を探りました。
試行錯誤の結果、下記の値でそれなりに表示されました。
      cfg.memory_width  = 720; // 出力解像度 幅
cfg.memory_height = 418; // 出力解像度 高さ
cfg.panel_width = 700; // 実際に使用する幅 (memory_width と同値か小さい値を設定する)
cfg.panel_height = 401; // 実際に使用する高さ (memory_heightと同値か小さい値を設定する)
cfg.offset_x = 12; // 表示位置を右にずらす量 (初期値 0)
cfg.offset_y = 14; // 表示位置を下にずらす量 (初期値 0)


上下は赤い線を確認できます。
左右は表示が滲むため赤を確認できませんが、3ピクセル分が描画されているであろう幅を確認できる描画領域にしました。



余談: setColorDepth(16)を呼ぶと、メモリ消費量と色数が増える

使い方のプログラムのコメントでもあるようにsetColorDepth(16)を呼ぶと扱う色の数を増やせます。
標準は8なので、16を設定するとメモリ消費量と色の数が倍になります。

setColorDepthは一度呼べば良いのでsetup関数内で呼びます。
void setup(void) {
gfx.setColorDepth(16);
gfx.init();
// other process
}

下記画像はsetColorDepth(16)あり


下記画像はsetColoDepthの呼び出しなし(標準の8)


setColorDepth(16)ありだとグラデーションの色の変化がより細やかになりました。

余談: v0.0(Rev0)のESPだと回路に不具合があってちらつくものの、出力は可能

ICのバージョンはバージョン確認用のプログラムやesptoolのchip_idオプションで確認できます。

ESP32が出始めた頃に購入したICのバージョンが0.0のESP32を利用するとNTSCで出力できないしPAL_M形式では色がおかしいと情報共有したところ、ライブラリLovyanGFXの作者のらびやんさんがRev0のESPでもそれなりに出力できるよう対応してくれました。


対応内容はこちらです。
Fixed a bug in ESP32 rev0 that caused video distortion when Panel_CVBS was used.

ということで、developブランチを利用して動かしてみます。
platformio.ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
https://github.com/lovyan03/LovyanGFX.git#develop

Rev0のESP32を利用してNTSC_J形式で映像を出力できました。


しかしながら、Rev1以後のESP32に比べると細かい描画が波打っていました。
綺麗な表示を求める場合は、Rev1以上のESP32を使うのが良さそうです。


ちなみに、LovyanGFX v1.1.2を利用した場合の様子はこちらです
NTSC出力では白黒画面の表示が出たり消えたりしました。


PAL_M形式だと何とか出力できたものの、色がおかしかったです。


終わり

LovyanGFXを利用してESP32からNTSCの信号をRCAケーブルを介してモニタに送信し、縦横比を維持して700x401pxの描画ができました。
このモニタはフルHD1920x1080pxなので、画面の最高画質の約0.36倍の解像度で描画できました。
(縦横の倍率(縦0.3713倍 横0.3646倍)が合わないので、正確な比率にするには更に調整が必要ですが、一旦終わりにします。)

表示の粗さを許容できるなら、HDMIではなくNTSCを利用して画面周辺の機材の総額を抑えられそうです。

参考

lgfx::Panel_CVBS for ESP32
External RAM (PSRAM)

変更履歴

2023.02.12 らぴやんさんによるRev0のESP32に対するライブラリの更新があったので、その情報を追加しました

0 件のコメント :