ffmpegを用いて動画から画像を取り出す【C#】
「なんか機械学習で遊びてーなー」と思いまして。機械学習フレームワークに食わせる前処理として、動画から画像を取り出す処理が必要になったので調べたのをまとめました。
ソースコード全文はGistに上げてます。
指定時間の画像を1枚取り出す
取り出した画像を直接ローカルストレージに保存するver
// [Download FFmpeg](https://www.ffmpeg.org/download.html)
static readonly string FfmpegPath = @"C:\Lib\ffmpeg-20170824-f0f4888-win64-static\bin\ffmpeg.exe";
/// <summary>動画ファイルから画像を抽出し、ストレージに保存する</summary>
public void ExtractImage2LocalStorage(string inputMoviePath, string outputImagePath, TimeSpan extractTime)
{
var arguments = $"-y -ss {extractTime.TotalSeconds} -i \"{inputMoviePath}\" -vframes 1 -f image2 \"{outputImagePath}\"";
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo
{
FileName = FfmpegPath,
Arguments = arguments,
CreateNoWindow = true,
UseShellExecute = false,
};
process.Start();
process.WaitForExit();
}
}
オプション | 説明 |
---|---|
y | 出力先に画像があった場合、上書きする |
ss | 初期のシーク時間(秒) |
i | 入力ファイルパス |
vframes | 処理フレーム数 |
f | 出力フォーマット。image2で画像を指定している。 |
とりあえずコレを使えればどうにかなると思う。
パイプで画像を取り出すver
/// <summary>動画ファイルからパイプを用いて画像を抽出する</summary>
public Image ExtractImageByPipe(string inputMoviePath, TimeSpan extractTime)
{
var arguments = $"-ss {extractTime.TotalSeconds} -i \"{inputMoviePath}\" -vframes 1 -f image2 pipe:1";
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo
{
FileName = FfmpegPath,
Arguments = arguments,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true, // 標準出力をリダイレクトするのが肝
};
process.Start();
var image = Image.FromStream(process.StandardOutput.BaseStream);
process.WaitForExit();
return image;
}
}
オプション | 説明 |
---|---|
pipe:1 | 出力結果を標準出力に流すようにする |
一々ローカルストレージに保存したくないなーってときに使う。パイプで流したバイナリデータの取得周りで結構ハマりました。
連続した画像を取り出し、ストレージに連番ファイル名で保存するver
/// <summary>ひたすらシークしまくって一定時間ごとに画像抽出する</summary>
public async Task ExtractImagesAsync(string inputMoviePath, string outputImageDir, TimeSpan interval)
{
var duration = GetMovieDuration(inputMoviePath); // see [Gist](https://gist.github.com/kokeiro001/a8a6194296ea7973a55c6fe3c2865cf2#file-imageextractor-cs-L176-L197)
// 処理対象時間を列挙する
var seekSecEnum = Enumerable.Range(0, (int)(duration.TotalSeconds / interval.TotalSeconds))
.Select(x => new { SeekSec = x * interval.TotalSeconds, No = x });
var tasks = seekSecEnum
.AsParallel()
.Select(x => Task.Run(() =>
{
var outputImagePath = Path.Combine(outputImageDir, $"{x.No:D4}.jpeg");
var arguments = $"-y -ss {x.SeekSec} -i \"{inputMoviePath}\" -vframes 1 -f image2 \"{outputImagePath}\"";
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo
{
FileName = FfmpegPath,
Arguments = arguments,
CreateNoWindow = true,
UseShellExecute = false,
};
process.Start();
process.WaitForExit();
}
}));
await Task.WhenAll(tasks);
}
画像1枚を取り出すときのやつをガリガリ回すだけ。fpsじゃなくてintervalで時間間隔指定してるのは、手元で作業する際に「5秒毎に1枚欲しい」「1分ごとに1枚欲しい」といった要望が多かったため。
連続した画像をパイプで取り出すver
/// <summary>ひたすらシークしまくりつつパイプ使って一定時間ごとに抽出する</summary>
public async Task<Image[]> ExtractImagesByPipeAsync(string inputMoviePath, TimeSpan interval)
{
var duration = GetMovieDuration(inputMoviePath);
var seekSecEnum = Enumerable.Range(0, (int)(duration.TotalSeconds / interval.TotalSeconds))
.Select(x => new { SeekSec = x * interval.TotalSeconds, No = x });
var tasks = seekSecEnum
.AsParallel()
.AsOrdered()
.Select(x => Task.Run(() =>
{
var arguments = $"-loglevel error -ss {x.SeekSec} -i \"{inputMoviePath}\" -vframes 1 -f image2 pipe:1";
var process = Process.Start(new ProcessStartInfo(FfmpegPath, arguments)
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = false,
});
var image = Image.FromStream(process.StandardOutput.BaseStream);
process.WaitForExit();
return image;
}));
return await Task.WhenAll(tasks);
}
これも画像1枚を取り出すときのやつを並列でガリガリ回すだけ。Task.WhenAll
で全ての抽出処理が終わるのをガッツリ待っているため、抽出対象の画像数が多い、画像サイズが大きいとメモリがパンパンになるので注意。
連続した画像を取り出し、ストレージに連番ファイル名で保存するver2(なんか遅い)
/// <summary>ffmpegのrオプション使って一定時間ごとに画像抽出する</summary>
public void ExtractImagesByOptionR(string inputMoviePath, string outputImageDir, TimeSpan interval)
{
var fps = 1.0 / interval.TotalSeconds;
var arguments = $"-y -i \"{inputMoviePath}\" -r {fps} -f image2 \"{outputImageDir}%04d.jpg\"";
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo
{
FileName = FfmpegPath,
Arguments = arguments,
CreateNoWindow = true,
UseShellExecute = false,
};
process.Start();
process.WaitForExit();
}
}
わざわざ並列でゴリゴリ回さなくても、ffmpegにあるrオプションを使うことでfps指定して画像を取得することが出来ます。簡単!なのですが、手元での処理では先述の2つより3倍くらい遅かったです(20分程度の動画から5秒ごとに画像抽出する場合)。パラメータ変えたり具体的なベンチマークとったりしてませんが、結構な差が出たので目的に応じて処理方法を変える必要があるかもしれません。
以上です。ffmpegはオプション変えることでいろんな処理できて面白いですね!ffmpegは使用するオプションの有無だけでなく、オプションの順番を変えることでも処理速度にも大きな差が出てきます。最適なオプションを最適な順番で使えるようになりたいものです。
(並列処理周りの記述方法、実はあまり自信がありません。なんかおかしいぞ!もっと効率よくできるぞ!といったアドバイスあったらぜひ教えてほしいです。)