ESP32と小さなOLEDでアニメーションを滑らかに再生したくて、既存の動画フォーマットだと色々と都合が悪かったので、1bpp専用の動画コーデックを自作しました。ほとんどBad Apple!!を再生するためだけに存在するようなニッチなコーデックです。
背景 / 概要
ESP32は安価な割にスペックが高いので、動画も再生できそうだなと思って、やってみようと思い立ったのが始まりです。ただ、よく使われているSSD1306の128×64 OLEDはモノクロ1ビット/ピクセルの表示しかできないうえ、ESP32側のRAMもフラッシュも潤沢ではありません。既存の動画コーデックをそのまま持ち込むにはオーバースペックですし、動画全体をメモリに載せるような実装も現実的ではありませんでした。
必要だったのは、以下のような特性を持つフォーマットです。
- フレームは最初から1bpp(白黒2値)前提でよい(カラー情報が要らない)
- デコーダはストリーミングで動作し、フレームを1枚ずつ処理できる(動画全体をバッファしない)
- Bad Apple!!のような、静止部分の多い映像に強い圧縮ができる
- PC側のエンコーダとESP32側のデコーダでロジックが食い違わない
これを満たすものが見当たらなかったので、TMG1という独自フォーマットと、そのコーデック一式を作ることにしました。
実機再生
先にどんなものができたか、実際にBad Apple!!本編を再生してみた様子です。
ESP32ボードは Seeed Studio XIAO ESP32C3 を使用しています。
加えて、今回のためにMMDで初音ミクの白黒アニメーションも作成しました。
ESP32用デモプロジェクトにサンプルデータとして付属しています。
ℹ️いずれもサウンドは後付けです。
それぞれ15fpsながら動画ファイルサイズは530KBと668KBという具合にコーデックによる圧縮が効いています。
TMG1について
エンコーダとデコーダでコード共有
コーデック本体のソースコードを独立させ、エンコーダとデコーダで同じC++コードを共有する設計にしています。tmg1-codecという単一のC++17ライブラリが、PC側(エンコード・デコード両方)とESP32側(デコードのみ)の両方で使われます。フォーマットの解釈がPCとデバイスで少しでもズレると、”エンコード時は正常だったのに実機では再生が崩れる”という事故につながるので、そこを構造的に防ぐ形にしました。
ESP32側ではtmg1-codec自体がArduinoライブラリとして振る舞うようになっていて(library.properties同梱)、PlatformIOのlib_depsでGitタグを指定して取り込むだけで使えます。
エントロピー符号化:Rangeコーダ / Riceコーダの2本立て
圧縮の核になる部分は、64bit RangeコーダとRiceコーダの2種類を選べるようにしています。どちらも「ライン単位で処理し、空行(変化なし)は1bitのフラグだけで済ませる」という点は共通で、その先の符号化方式が異なります。
Riceコーダの仕組み
Riceコーダは、ランレングス符号化(RLE)とGolomb-Rice符号を組み合わせたものです。ラインを0/1が連続する区間(ラン)に分解し、それぞれのラン長nを、パラメータkで決まる商qと余りrに分けて符号化します。商qはnをkビット右シフトした値をunary符号(0をq個並べて終端に1を置く、など)で、余りrは下位kビットをそのまま2進数で表現します。kの決め方はFixed / PerLine / PerFrameの3モードから選べます。計算コストが軽く、tmg1-cliのデフォルトコーダにもなっています。
flowchart TD
A["ラン長 n"] --> B["n >> k → 商 q"]
A --> C["n の下位kビット →<br/>余り r"]
B --> D["qを unary符号化<br/>(0がq個 + 終端の1)"]
C --> E["rを kビットの<br/>2進数で符号化"]
D --> F["unary部 + binary部を<br/>連結して出力"]
E --> F
Rangeコーダの仕組み
Rangeコーダは、割り算ベースの算術符号化です。__uint128_tを使わない実装にしてあり、これはMSVCでもビルドできるようにするためです。直前のビットの値をコンテキストとして使う適応的な確率モデルを持っていて、ビットが出るたびにlowとrangeという2つの64bit整数で表される数値区間を、確率に応じて狭めていきます。区間が十分小さくなったら上位バイトを出力して正規化する、という流れを1ビットごとに繰り返します。圧縮率を優先したいときの選択肢です。
flowchart TD
A["1ビットずつ処理"] --> B["直前ビットの値で<br/>確率モデルを選択"]
B --> C["モデルの確率で<br/>rangeを分割(low、幅)"]
C --> D["実際のビット値側の<br/>区間にrangeを絞り込む"]
D --> E{"rangeが十分小さい?"}
E -->|"Yes"| F["上位バイトをlowから<br/>出力し正規化"]
F --> A
E -->|"No"| G["使ったモデルの<br/>頻度を更新"]
G --> A
予測フィルタでエントロピーを下げる
符号化の前段として、予測フィルタ(None / Left / Up)をフレームごとに自動選択しています。Bad Apple!!のような単色シルエット主体の映像は、隣接ピクセル間の相関が強いので、フィルタをかけてから符号化した方が小さくなることが多いです。フレームごとに全パターンを試して、最も小さくなるフィルタを採用します。
flowchart TD
A["フレーム"] --> B["Noneフィルタで<br/>符号化"]
A --> C["Leftフィルタで<br/>符号化"]
A --> D["Upフィルタで<br/>符号化"]
B --> E{"サイズ比較"}
C --> E
D --> E
E --> F["最小サイズの<br/>フィルタを採用"]
差分フレームとシーンチェンジ検出
デルタ(Pフレーム)にも対応していて、前フレームとの差分だけを符号化できます。Bad Apple!!は背景が静止していて人影だけが動くカットが多いため、Pフレームの効きが良い映像です。
ただしPフレームには弱点があって、カット切り替えの直後は前フレームとの差分がほぼ無意味になり、逆にサイズが膨らむことがあります。これに対応するのがシーンチェンジ検出(SCD)で、対象フレームをIフレームとPフレームの両方で圧縮してみて、小さい方を採用する仕組みです。愚直ですが確実です。
flowchart TD
A["対象フレーム"] --> B["Iフレームとして<br/>符号化"]
A --> C["Pフレーム(前フレーム差分)<br/>として符号化"]
B --> D{"サイズ比較"}
C --> D
D --> E["小さい方を採用"]
可変フレームレート(VFR)
同じフレームが連続する区間は、フレームを再送する代わりに表示時間(ptsDelta)を積算するだけにする仕組みも入れています。静止したカットが多い映像だと、これだけでかなりファイルサイズが縮みます。再生側(ESP32のデコーダ)はptsDeltaをもとにマイクロ秒精度でフレームの表示時間を調整するので、可変フレームレートのまま等速再生できます。
flowchart TD
A["同一フレームが連続"] -->|"再送しない"| B["ptsDeltaを積算"]
B --> C["フレーム1枚 +<br/>表示時間情報を出力"]
C -->|"再生側"| D["ptsDeltaぶん<br/>表示し続ける"]
設定を変えて計測してみた
ここまでの設計はだいたい「こう効くはず」という想定の話なので、実際にtmg1-cliで設定を7パターン変えてエンコード・デコードし、サイズとデコード時間を計測してみました。入力はBad Apple!!本編の映像(128×64@15fps、3288フレーム、元サイズ3,366,912 bytes)です。
| # | 設定 | サイズ (bytes) | 元比 | decode時間/frame |
|---|---|---|---|---|
| 1 | rice / fixed(k=1) / delta,pred,scd,vfr 全OFF(最大) | 1,806,926 | 53.7% | 44µs (0.56x) |
| 2 | rice / per-line / 全機能ON(最速) | 715,510 | 21.2% | 34µs (0.43x) |
| 3 | rice / per-frame / 全機能ON(現行デフォルト) | 682,768 | 20.3% | 36µs (0.46x) |
| 4 | range / intra only(delta,pred,scd,vfr OFF、最重) | 676,520 | 20.1% | 89µs (1.13x) |
| 5 | range / delta+scd+vfr, prediction OFF | 566,664 | 16.8% | 80µs (1.01x) |
| 6 | range / 全機能ON | 541,735 | 16.1% | 79µs (1.00x基準) |
| 7 | range / 全機能ON + --index |
567,381 | 16.9% | 75µs (0.95x、誤差範囲) |
decode時間はdesktop CPU上でcli decodeを5回実行した最小値です。coder(rice/range)はtmg1-codecのC++実装をESP32とデスクトップで共有しているので相対比較の参考にはなりますが、ESP32上の絶対値そのものではない点は注意してください。
わかったこと
- decode負荷はほぼcoderの種類で決まります。rice系(#1〜3)はrange系(#4〜7)より概ね2倍速いです。
- delta/prediction/scd/vfrのON・OFFは、range系(#4〜7)の中ではdecode時間にほとんど差が出ず、誤差数%の範囲でした。
- rice系の中では「全機能ONの#2/#3」の方が「全部OFFの#1」より速いです。ファイルが軽い=読み出すビット数が減るので、その分デコードも速くなるという関係のようです。
--index(#7)は索引チャンク分でサイズが約26KB(4.7%)増加します。今のリポジトリの再生は逐次再生のみでシークしないので、現状は付けても恩恵がありません。
flash/LittleFS容量を最優先するなら、Rangeコーダ(#6: range / 全機能ON)を選ぶべきです。ESP32側のCPU負荷に余裕がなく速度を優先したい場合は、#2(rice / per-line)が候補になります。サイズは+32%ほど増えますが、decode負荷はおよそ半分です。
リポジトリ構成
役割ごとに3つのリポジトリに分けています。
tmg1-codec(C++17):エンコーダ・デコーダ本体。PC向けとArduino/ESP32向けの両方をカバーする共有コアtmg1-cli(Rust):PC側のコマンドラインツール。tmg1-codecをFFI経由で呼び出すだけの薄いフロントエンドで、transcodeサブコマンドはffmpegをラップして、任意の動画ファイルからそのまま.tmg1を生成できるtmg1-esp32-demo:ESP32上でU8g2 + SSD1306 OLEDに再生する実機デモ。デコード処理そのものは持たず、tmg1-codecをライブラリとして呼び出す構成
これらのリポジトリおよび仕様書は下記のOrganizationにまとまっています。

まとめ / 課題点
1bppという極端に絞った色数を前提にすることで、汎用動画コーデックよりもだいぶシンプルな作りで実機再生まで持っていけました。RangeコーダとRiceコーダの選択、予測フィルタ、デルタ+シーンチェンジ検出、VFRという組み合わせは、Bad Apple!!のような「静止と動きが極端に分かれる映像」との相性が良かったように思います。
対応解像度や色数を増やす予定は今のところありませんが、シーク用のTMGXインデックスなど、作った割にまだ使い切れていない機能もあるので、その辺りはまた気が向いたら書きたいと思います。
あとは実機で音声も同時再生してみたいな~なんて考えています。
ご意見・ご質問はお気軽にどうぞ😊


コメント