M5Stack Tab5のJPEGレンダリングの話

以下のように、M5Stack Tab5でJPEGレンダリングを使用するものをいくつか作っています。
- https://github.com/Hiroki-Kawakami/Tab5-UVC-Display
- https://github.com/Hiroki-Kawakami/Tab5-Media-Player
- https://github.com/Hiroki-Kawakami/Tab5-Screen-Streamer
これらで高速描画するためにいくつか工夫をしているのでその話を。
JPEG画像をそのまま表示する
普通の手段
おそらくほとんどの人はM5Stackの開発にM5Unified(M5GFX)を使っていると思います。そうすると display.drawJpg を使えば簡単にJPEG画像を表示できます。
ここで使われるのは TJpgDecという軽量なJPEGデコーダー。ソフトウェア実装なのでどんなデバイスでも動く便利なやつです。
一方、これはすべての処理をCPUで行う必要があるため、720x1280の画像を高速でレンダリングするにはしんどいです。
実際に、Tab5-Screen-StreamerをM5Unified環境で動かすと、大体3~4fpsくらいになります。その上、レンダリング途中のフレームバッファが表示されるため、300msくらいかけて画面が端から端まで書き換わっていくのが見える形になり、動画再生には耐えかねます。
#include "M5Unified.h"
void setup() {
M5.begin();
}
void draw(const uint8_t *jpeg_data, size_t data_len) {
M5.Display.drawJpg(jpeg_data, data_len, 0, 0, 720, 1280);
}そこで、M5Unifiedのようなどんなデバイスでも動くような汎用性は捨て去って、Tab5/ESP32-P4に特化した実装を行うことで高速化を図ります。
esp_new_jpeg
M5GFXに同梱されているTJpgDecはどんなデバイスでも動かせる汎用的な実装である一方で、EspressifがESP32シリーズのSoCで動作させる用に調整を入れたJPEGライブラリを公開しています。

Detail of component espressif/esp_new_jpeg - 1.0.0
具体的に何が違うかというと、ESP32-S3 と ESP32-P4 でエンコード/デコード時にSIMD演算命令が使用されるようになります。
#include "M5Unified.h"
#include "esp_jpeg_common.h"
#include "esp_jpeg_dec.h"
M5Canvas canvas;
void *sprite_buffer;
jpeg_dec_handle_t jpeg_dec = NULL;
void setup() {
M5.begin();
canvas.setPsram(true);
sprite_buffer = canvas.createSprite(720, 1280);
if (!sprite_buffer) {
M5.Display.println("Buffer Allocation Failed!");
}
jpeg_dec_config_t config = DEFAULT_JPEG_DEC_CONFIG();
config.output_type = JPEG_PIXEL_FORMAT_RGB565_BE;
jpeg_dec_open(&config, &jpeg_dec);
}
void draw(const uint8_t *jpeg_data, size_t data_len) {
jpeg_dec_io_t io = {
.inbuf = (uint8_t*)jpeg_data,
.inbuf_len = (int)data_len,
.outbuf = (uint8_t*)sprite_buffer,
.out_size = 1280 * 720 * 2,
};
jpeg_dec_header_info_t out_info;
jpeg_dec_parse_header(jpeg_dec, &io, &out_info);
jpeg_dec_process(jpeg_dec, &io);
canvas.pushSprite(&M5.Display, 0, 0);
}フレームレートはそれでも3~4fpsくらいです。早くなってないですね。これには理由があって、esp_new_jpegを使う際に、直接フレームバッファにデコード後の画像を書き込んでいるのではなく、一度M5Canvasにデコードした画像を書き込んでからフレームバッファにpushSpriteしているからです。
なので、早くはなってないけど、フレームバッファへの書き込みは高速なmemcpyなため、端から徐々に描画されていく感はマシになってますね。
ならば、直接フレームバッファに書き込んだらどうなるでしょうか。
自分の手元で動かしているM5GFX v0.2.9だと、以下のようにすれば画面のフレームバッファを取得することができます。(もちろんTab5以外では動かないです)
auto dsi = static_cast<lgfx::Panel_DSI*>(M5.Display.getPanel());
auto config_detail = dsi->config_detail();
void *frame_buffer = config_detail.buffer;これで取得できたフレームバッファのポインタをデコーダの出力バッファに指定すればいいです。
#include "M5Unified.h"
#include "lgfx/v1/platforms/esp32p4/Panel_DSI.hpp"
#include "esp_jpeg_common.h"
#include "esp_jpeg_dec.h"
void *frame_buffer;
jpeg_dec_handle_t jpeg_dec = NULL;
void setup() {
M5.begin();
auto dsi = static_cast<lgfx::Panel_DSI*>(M5.Display.getPanel());
auto config_detail = dsi->config_detail();
frame_buffer = config_detail.buffer;
jpeg_dec_config_t config = DEFAULT_JPEG_DEC_CONFIG();
config.output_type = JPEG_PIXEL_FORMAT_RGB565_LE;
jpeg_dec_open(&config, &jpeg_dec);
}
void draw(const uint8_t *jpeg_data, size_t data_len) {
jpeg_dec_io_t io = {
.inbuf = (uint8_t*)jpeg_data,
.inbuf_len = (int)data_len,
.outbuf = (uint8_t*)frame_buffer,
.out_size = 1280 * 720 * 2,
};
jpeg_dec_header_info_t out_info;
jpeg_dec_parse_header(jpeg_dec, &io, &out_info);
jpeg_dec_process(jpeg_dec, &io);
}ちょっと早くなりましたがそれでも5fps程度。ちょっとだけですね。
なお、今回のケースでは連続してJPEG画像をレンダリングしていて、何もしなくてもcache write backが発生する状況なのでこれでも問題なく表示できていますが、静止画を表示してからしばらくメモリ操作を行わないようなユースケースだと明示的にcache flushしないと映像が破綻するようになるので注意です。
HW Jpeg Codec
さて、ここからが本命。ESP32-P4にはHW Jpeg Codecが搭載されており、これを使うと高速でJPEG画像のエンコード/デコードができます。
とはいえ実装は簡単で、esp_new_jpeg を使っていたところを esp_driver_jpeg を使うように書き換えるだけです。
#include "M5Unified.h"
#include "lgfx/v1/platforms/esp32p4/Panel_DSI.hpp"
#include "driver/jpeg_decode.h"
void *frame_buffer;
jpeg_decoder_handle_t jpeg_dec = NULL;
void setup() {
M5.begin();
lgfx::Panel_DSI *dsi = static_cast<lgfx::Panel_DSI*>(M5.Display.getPanel());
auto config_detail = dsi->config_detail();
frame_buffer = config_detail.buffer;
jpeg_decode_engine_cfg_t config = {
.intr_priority = 0,
.timeout_ms = 100,
};
jpeg_new_decoder_engine(&config, &jpeg_dec);
}
void draw(const uint8_t *jpeg_data, size_t data_len) {
jpeg_decode_cfg_t config = {
.output_format = JPEG_DECODE_OUT_FORMAT_RGB565,
.rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_BGR,
.conv_std = JPEG_YUV_RGB_CONV_STD_BT601,
};
uint32_t out_size;
jpeg_decoder_process(jpeg_dec, &config, jpeg_data, data_len, (uint8_t*)frame_buffer, 1280 * 720 * 2, &out_size);
}これだけで60fpsで安定して描画できるようになりました。すごいですね。ちなみに Tab5 Media Player は概ねこれと同じような実装で動いています。
高速描画するだけならばこれで問題ないですし、実際これが一番高速に描画できる方法です。むしろTab5の性能的にはもうちょっと余裕があるので、ここから画質の向上をしていきます。
ダブルバッファ
HW Jpeg Decoderからの出力を直接フレームバッファに書き込んでいました。この場合、デコーダがフレームバッファに画像を書き込んでいる間もDMAでフレームバッファがLCDに転送され続けるので、画像が途中まで書き換わった状態がLCDに表示されてしまいテアリングが発生します。
デコーダの速度が十分速いため気にならないことも多いのですが、動きの激しい映像や画面全体がフェードするようなものを表示すると縦に映像が分断されるのがわかります。
これを防ぐために、フレームバッファを2枚用意してデコーダの出力とLCDへの転送を交互に行うことにしましょう。
デコーダはLCDに表示されていない方のフレームバッファに画像を出力して、出力し終わってから表示するフレームバッファを切り替えることでデコード途中の画像が表示されるのを防ぐ感じです。
ちなみにこれ以降の実装はドライバを直接いじる必要があり、M5Unifiedが使えないため、ドライバは自作しましょう。
esp_lcd_dpi_panel_config_t の num_fbs を2にすることで、ドライバ内で確保されるフレームバッファを2枚にすることができます。M5Tab5-UserDemoの実装だとここですね。
あとは esp_lcd_dpi_panel_get_frame_buffer を使うとフレームバッファのポインタを2枚分取得できます。
これを使ってレンダリングする際ですが、フレームバッファの切り替えには esp_lcd_panel_draw_bitmap を使います。
esp_lcd_panel_draw_bitmapは実装を見ると、
color_dataに渡されたポインタがフレームバッファの範囲内ならcache flush & フレームバッファ切り替え- そうでなければ
color_dataをフレームバッファにコピー
という感じの挙動なので、とりあえずこいつに切り替え先のフレームバッファのポインタを渡しておけば大丈夫です。
あれれ、まだテアリングが発生していますね。別の要因が残ってそうです。
リフレッシュレートの調整
ダブルバッファにしたのにテアリングが発生するということは、まだ1枚目のフレームバッファをLCDに転送している最中なのに、JPEGデコーダが2枚目のフレームバッファへの出力を終えて1枚目のフレームバッファに書き込みし始めてしまっていそうです。
垂直同期を実装していないため、LCDへの転送よりもJPEGデコードが早いとこうなります。
今回の場合、PCからJPEG画像を最大60fpsで転送しており、JPEG Decoderはその速度での処理ができているため、そのままフレームバッファには秒間60枚の画像が書き込まれます。
一方で、LCDへの出力タイミング、今回で言うとリフレッシュレートがどのようになっているかというと、esp_lcd_dpi_panel_config_t の dpi_clock_freq_mhz と video_timing によって設定されています。M5Tab5-UserDemoだとこの部分です。
リフレッシュレートの計算式は
dpi_clock_freq_mhz / (hsync_pulse_width + hsync_front_porch + hsync_back_porch + h_size) / (vsync_pulse_width + vsync_front_porch + vsync_back_porch + v_size)なので、Tab5-UserDemoと同じパラメータを使った場合は 600000000 / (40 + 40 + 140 + 720) / (4 + 20 + 20 + 1280) := 48.2 となって48.2Hzで動作していることがわかります。
これはちょっと遅いですね。60fps描画しようとしているところに間に合っていないのもそうなのですが、そもそもILI9881C自体の推奨値が50~60fpsなので、48.2はそれを下回っています。
そこで、60fpsになるように dpi_clock_freq_mhz を上げます。60 * (40 + 40 + 140 + 720) * (4 + 20 + 20 + 1280) == 74.6736 なので、 dpi_clock_freq_mhz を75にしてみましょう。あと、そうするとバスの転送速度が足りなくなるので、 lane_bit_rate_mbps も800くらいに上げておきます。
依然としてテアリングは発生しますが、前よりも随分マシになりました。これ以上テアリングを解消するには垂直同期を実装するか、PSRAMに余裕があるならトリプルバッファにするのがいいでしょう。
True Color
RGB565(16bit color)で動作させてますが、Tab5はRGB888(24bit color)いけます。発色/諧調表現が良くなります。esp_lcd_dpi_panel_config_t の pixel_format in_color_format out_color_format をRGB888にして lane_bit_rate_mbps も870に上げればOKです。
ただし、この場合フレームバッファのデータ量がシンプルに1.5倍になるので、60fps安定動作が厳しくなります。表示内容によっては60fpsいけるのですが、55fpsくらいまでdropすることが多いです。
とはいえ55fpsでも十分早いですし、発色がいい方が全体的な画質としてはいいと思ったので、Tab5 Screen Streamerではフレームレートを多少犠牲にしてでもRGB888で動作させています。
回転・拡大縮小して表示
Tab5のLCDは720x1280の縦方向のLCDなので、1280x720の映像を表示したい場合は90度回転させる必要があります。
表示前に回転・拡大縮小が必要な場合はPPAを使いましょう。
とはいえ、JPEG DecoderとPPAを一緒に動かすとPSRAMの帯域が足りず、720pのMJPEGをレンダリングをするのにRGB565で20fpsくらいが限界になります。
Tab5 UVC Displayはこれで動いています。色々調整してRGB888で30fpsくらい出したかったんだけど上手くいきませんでした。
以上、あんまり参考にならないかもしれないけど、色々工夫すれば結構いい感じにレンダリングできるよって話でした。

