はじめに
見習いエンジニアの学生です。本プロジェクトは僕自身がC/C++を勉強、及び最適化に関する勉強をするために開発しました。ロマンあふれるものになります。
普段は高専ロボコンで、ロボット制御を担当しています。
本記事の内容は、授業や講義ではなく、すべて個人で試行錯誤しながら実装・検証したものです。
AIとネット上の情報に助けられました。
アスキーアート(ASCII Art, AA)とは
コンピューターの文字(英数字や記号)を縦横に並べて、絵や図、キャラクターなどを表現する技法です。
- 顔文字:
(^_^)のように短い表情を表すもの - 数行〜数百行の文字で複雑なイラストを描くテキストアート
Bad Apple!!
「ロマン」の話ですが、エンジニア界隈には「画面があるものなら何でも『Bad Apple!!』を流す」という文化があります。(電卓、オシロスコープ、タスクマネージャーなど)
完成品
フル動画
ターミナルで Bad Apple!! を120FPS 文字だけで再現してみた
動作環境
CPU: Intel Core i5 12400F
メモリ: 32GB
GPU: RTX 4060
OS: Ubuntu 24.04 LTS
また、以下の環境でも動作確認しています。
CPU: Intel Core i5 1235U
メモリ: 16GB
GPU: Intel内蔵
OS: Ubuntu 22.04 LTS
ASCIIアート化・同期再生システム
OpenCVとマルチスレッドを用いて、MP4動画をコンソール上で「ASCIIアート(文字列)」としてリアルタイム再生し、音声と同期させるシステムの内部構造を解説していきます。
- 動画フレームの画像解析と文字列変換(点字フォント活用)
- 外部コマンドによる音声抽出
- 音声再生と映像描画の精密な時間同期
ターミナルという制約の中で、高フレームレートかつスムーズな再生を実現するためにプロデューサー・コンシューマーモデルを採用しています。
後から調べて分かりましたが、この構成はいわゆる「プロデューサー・コンシューマーモデル」でした。
使用ライブラリ
-
OpenCV (C++)
映像取得・画像処理、高速なフレーム読み込み、リサイズ、グレースケール変換機能のため。 -
SFML (Audio)
軽量で扱いやすく、BGMの再生が容易であるため。 -
FFmpeg (CLI)
動画からWAV形式へ音声を分離・変換する前処理用として、system()関数経由で使用。 -
OpenMP
ASCII変換処理のループを並列化し、CPU使用率を効率化するため。 -
Standard Library
std::threadstd::mutexstd::chronoを使用し、処理落ちを防ぐ並列構造を実装。
処理の流れ
システムは大きく分けて「初期化フェーズ」と、2つの並列スレッドによる「再生フェーズ」で構成されています。
前処理(初期化)
プログラム起動時、即座にFFmpegコマンドを発行し、動画ファイルから音声データを抽出します。
// 外部コマンド呼び出しによるWAV生成
std::string commands = "ffmpeg -y -i " + FILENAME + " -vn output.wav";
system(commands.c_str());
並列処理モデル
処理速度を最大化するため、「フレーム生成」と「描画」を別スレッドに分割しています。
フレーム生成スレッド
動画データの読み込みと変換を担当します。
- Read
cv::VideoCaptureでフレームを取得 - Process: 画像をリサイズ・グレースケール化し、ASCII文字列へ変換
- Buffer: 変換済みの文字列データを
std::vector<std::string>に格納
※排他制御(Mutex)により、描画スレッドとのデータ競合を防止。
描画・再生スレッド
画面への出力と同期を担当します。
- Play: SFMLで音声を再生開始
- Sync: 経過時間を計測し、現在の時間に合致するフレームをバッファから取得
- Draw: 標準出力(stdout)へ高速に書き出し
- Clean: 表示済みフレームのメモリを即座に解放
コアロジックの解説
アスペクト比の補正
コンソールのフォントは縦長であるため、通常の比率でリサイズすると映像が潰れてしまいます。これを防ぐため、リサイズ時に特殊な係数を掛けています。
cv::Mat resize(const cv::Mat& image, int new_height) {
int old_width = image.cols;
int old_height = image.rows;
float aspect_ratio = static_cast<float>(old_width) / static_cast<float>(old_height);
int new_width = static_cast<int>(aspect_ratio * new_height * 2.65);
cv::Mat resized_image;
cv::resize(image, resized_image, cv::Size(new_width, new_height));
return resized_image;
}
高密度ASCIIアート変換
通常のアルファベットではなく、点字(Braille Patterns)を使用することで、より繊細な濃淡表現を実現しています。
- マッピング配列:
{"⣿", "⣾", ... " "} - ロジック: 画素の輝度(0〜255)をバケットサイズ(25)で割り、対応する点字文字に置換
- OpenMPによる並列化と、画素への直接的なポインタアクセスを組み合わせることで、CPUリソースを最大限に活用
std::string modify(const cv::Mat& image) {
std::vector<std::string> output(image.rows);
#pragma omp parallel for
for (int i = 0; i < image.rows; ++i) {
std::ostringstream oss;
const uchar* row_ptr = image.ptr<uchar>(i);
for (int j = 0; j < image.cols; ++j) {
int pixel_value = row_ptr[j];
oss << ASCII_CHARS[pixel_value / 25];
}
oss << "\n";
output[i] = oss.str();
}
std::ostringstream final_output;
final_output << "\033[H";
for (const auto& line : output) {
final_output << line;
}
final_output << "\033[0m";
return final_output.str();
}
※ std::ostringstream は高速とは言えないため、さらなる最適化余地(固定バッファ化等)はあります。
高速描画
通常、C++での出力には std::cout や printf を使いますが、これらはバッファリングやフォーマット解析のオーバーヘッドがあり、大量のテキストを書き換える用途には速度不足で不可能でした。
そのため、低レイヤーのシステムコールである write() 関数を使用し、標準出力(ファイルディスクリプタ1)へ直接バイト列を流し込むことで、描画のボトルネックを解消しています。
#include <unistd.h>
// std::cout << frame_data; ではなく...
// ファイルディスクリプタ 1 (stdout) に直接書き込む
write(STDOUT_FILENO, frame_data.c_str(), frame_data.size());
ANSI制御の最小化
ターミナル描画では、ANSIエスケープシーケンスのコストが無視できません。
そのため、本実装では以下の方針を取っています
- 使用するANSI制御は最小限(
\033[Hと必要最低限のリセットのみ) - clear screen (
\033[2J) や行単位のカーソル移動は使用しない - 1フレーム分の描画データを1つの連続したバッファにまとめ、
write()で一括出力
これにより、ターミナル側の状態遷移回数と描画負荷を大幅に削減しています。
差分描画も試したが、今回は逆効果だった
描画負荷を下げるため、前フレームとの差分のみを描画する方式も検討・実装しました。
しかし結果としては、全面描画よりも圧倒的に遅くなりました。
主な理由は以下です。
- Bad Apple!! はフレーム間の変化量が大きく、差分量が少なくならない
- 差分検出のための比較処理・分岐が増え、CPU負荷が上昇
- 行単位・部分描画により、ANSIカーソル制御が増加
このため「全面描画+ANSI制御最小化」という戦略を採用しています。
音声同期
単純な sleep だけでは処理遅延により音ズレが発生します。
- 開始時刻の記録: 音楽再生と同時に
std::chronoで計測開始 - フレームスキップ
// 経過時間から「今あるべきフレーム番号」を算出
int expected_frame_index = static_cast<int>(elapsed_time.count() * fps);
// 現在の描画位置が遅れている場合、追いつくまでループ(フレームを飛ばす)
while (i < expected_frame_index ...) { ++i; }
これにより、描画処理が重くなった場合でも映像がコマ落ちするだけで、音声とのズレは発生しません。
メモリ管理とパフォーマンス
動画全フレームを文字列としてメモリに保持すると、長時間の動画ではメモリ不足に陥ります。これを防ぐため、以下のメモリ管理を行っています。
- 動的なメモリ解放
描画スレッドが表示を終えたフレームデータは、即座にclear()およびshrink_to_fit()され、物理メモリから破棄
現状は可変長文字列を都度確保・解放しており、効率とコストが課題だと考え、固定長リングバッファ化を検討しています。
メモリ管理
大苦戦中ですので、参考にしないでください。
メモリムズカシイヨオオ
ビルド
OpenCV、SFML、OpenMPを使用し、最適化オプションが設定されています。
最適化オプション
target_compile_options(Bad-Apple PRIVATE
-O3
-march=native
-ffast-math
-fomit-frame-pointer
-flto
-fopenmp
)
-
-O3: 最も高いレベルの最適化 -
-march=native: 「今このコンパイルを行っているPCのCPU」に特化した命令セット -
-ffast-math: 浮動小数点演算の厳密な規格を無視して、計算速度を優先 -
-fomit-frame-pointer: デバッグ用の情報をレジスタから排除し、その分を計算用に回して高速化 -
-flto (Link Time Optimization): ファイル単位ではなく、プログラム全体を見渡して関数呼び出しなどを最適化 -
-fopenmp: OpenMPを使用するため
CUDAも試したが、今回は逆効果
ASCII変換処理をCUDAでGPUに投げればさらに高速化できると考えましたが、結果的にはCPU実装より遅くなりました。
主な原因は以下の通りだと考えています。
- フレームごとにCPU→GPU→CPUのメモリ転送が発生
- 1フレームあたりの処理量が小さく、カーネル起動コスト+転送オーバーヘッドが支配的
- ターミナル描画自体はCPU側で行う必要があり、GPU処理を活かしきれなかった
フレームをまとめてGPU側で完結させる構成(GPU描画や巨大バッチ処理)であれば、CUDAが有効になる可能性はあると思っています。
Alacritty
使用しているターミナルはAlacrittyというものです。
Rust製でGPUアクセラレーションを利用した高速なターミナルエミュレータです。シンプルさとパフォーマンスを重視して開発されています。
表示に関しては爆速ですのでこれを採用しています。
ソースコード
GitHub
mainブランチがBad Apple ASCII Art Playerになります。
color-v2ブランチがASCII Art Playerの発展系で、フルカラーで動画を再生します。
これは別の記事で紹介したいと思います。
終わりに
本来、文字を表示するだけの『ターミナル画面』で、映像と音楽をズレなく、しかも高画質に再生しようとする執念からここまできています。
