OpenCV でリアルタイム生成した画像を可変フレームレート動画として保存する

本記事は [OpenCV Advent Calendar 2021](https://qiita.com/advent-calendar/2021/opencv) 第17日の記事です

----

OpenCV で動画像処理をする典型的なパターンとして、(1) VideoCapture でウェブカメラなどから画像を取得 → (2)なんやかんやする → (3) VideoWriter で動画として保存、というやり方を取ることがよくありますが、この設計でよくぶつかる問題があります。
(1) のウェブカメラでは一定のフレームレートで取得することが出来たとしても、大抵の用途では (2) なんやかんやするの所で時間がかかる処理をするため、フレーム落ちすることがよくあります。また、かかる時間も一定ではないため (3) VideoWriter で設定する出力フレームレートを合わせないと、早送りしたようなおかしな動画になってしまいます。
結局何が問題かというと、VideoWriter はあまり機能が豊富でなく、固定フレームレートしかサポートしていないため、フレームレートを成り行きに合わせてくれないということです。

このような場合は、もっと機能豊富な ffmpeg （を Python から使う ffmpeg-python）を使って動画保存するのが良い方法です。

# ffmpeg-python で動画を保存するには
ffmpeg-python は ffmpeg プログラムを外部プロセスとしてうまいこと簡単に実行してくれるライブラリです。
自分のプログラムとの画像データのやり取りは、標準入出力をパイプとして繋げることで行います。
リアルタイム処理ではなく、単に画像を動画ファイルとして保存するには以下のようにします。

```python
process = (
    ffmpeg
    .input('pipe:', format='rawvideo', pix_fmt='bgr24',
           s='{}x{}'.format(width, height))
    .output('output.avi')
    .overwrite_output()
    .run_async(pipe_stdin=True)
)
process.stdin.write(image.astype(np.uint8).tobytes()) # これを必要なだけ繰り返す
# 最後にこれを入れないと動画ファイルの終端処理がされないので、
# 動画プレイヤーなどで再生できない壊れたファイルになってしまう
process.stdin.close()
process.wait()
```

この場合、標準入力のデータには画像の輝度値しか渡すことが出来ませんので、ffmpegはこのフレームがいつのものか（タイムスタンプ）がわかりません。そのため実時間ではなく固定のフレームレートとみなして保存してしまい、早送りしたような動画になってしまうのです。
リアルタイム生成でないときはなぜ問題にならないかというと、ffmpeg は通常はリアルタイム画像ではなく、既に保存された動画ファイルなどをバッチ処理することを目的としているからです（その場合は入力動画ファイルのフレームレートを知ることができるので、それに合わせることができる）。OpenCV の VideoWriter がうまく行かないのと同じ理由です。

# ffmpeg-python でリアルタイムの画像を可変フレームレート動画として保存するには
そこで、入力フィルターの `-use_wallclock_as_timestamps` オプションと、出力フィルターの `-vsync` オプションを使います。

```python
process = (
    ffmpeg
    .input('pipe:', format='rawvideo', pix_fmt='bgr24',
           s='{}x{}'.format(width, height), use_wallclock_as_timestamps=1)
    .output('output.avi', vsync='vfr', r=60.0)
    .overwrite_output()
    .run_async(pipe_stdin=True)
)
```

入力フィルターの `-use_wallclock_as_timestamps` オプションは、フレームのタイムスタンプにマシンの時計を使う設定です。これによって、プログラムから入力パイプを通して渡された画像データのタイムスタンプをリアルタイム時刻に決定することが出来ます。
出力フィルターの `-vsync=vfr` オプションは、出力フレームレートを可変にするオプションです。`-r` を共に使うと最大フレームレートを指定することが出来ます。

# 例
## パッケージのインストール
```sh
$ pip install ffmpeg-python opencv-python numpy
```

## ソース
「なんやかんやする」の部分は人それぞれなので、例では適当にリサイズ処理を入れておきます。

```python
import cv2
import ffmpeg
import numpy as np

width = 480
height = 320

cap = cv2.VideoCapture(0)
process = (
    ffmpeg
    .input('pipe:', format='rawvideo', pix_fmt='bgr24',
           s='{}x{}'.format(width, height), use_wallclock_as_timestamps=1)
    .output('D:/output.avi', vsync='vfr', r=60.0)
    .overwrite_output()
    .run_async(pipe_stdin=True)
)

while (True):
    ret, frame = cap.read()
    if not ret:
        break
    resized = cv2.resize(frame, (width, height))
    cv2.imshow('image', resized)
    if cv2.waitKey(1) != -1:
        break
    process.stdin.write(resized.astype(np.uint8).tobytes())

process.stdin.close()
process.wait()
```

## 実行時のログ

```
Input #0, rawvideo, from 'pipe:':
  Duration: N/A, start: 1639054782.520000, bitrate: 92160 kb/s
    Stream #0:0: Video: rawvideo (BGR[24] / 0x18524742), bgr24, 480x320, 92160 kb/s, 25 tbr, 25 tbn, 25 tbc
Stream mapping:
  Stream #0:0 -> #0:0 (rawvideo (native) -> mpeg4 (native))
Output #0, avi, to 'D:/output.avi':
  Metadata:
    ISFT            : Lavf58.29.100
    Stream #0:0: Video: mpeg4 (FMP4 / 0x34504D46), yuv420p, 480x320, q=2-31, 200 kb/s, 60 fps, 60 tbn, 60 tbc
    Metadata:
      encoder         : Lavc58.54.100 mpeg4
    Side data:
      cpb: bitrate max/min/avg: 0/0/200000 buffer size: 0 vbv_delay: -1
frame=  482 fps= 25 q=5.0 Lsize=     461kB time=00:00:19.18 bitrate= 196.8kbits/s speed=0.999x
video:428kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 7.653102%
[ WARN:0] global D:\a\opencv-python\opencv-python\opencv\modules\videoio\src\cap_msmf.cpp (438) `anonymous-namespace'::SourceReaderCB::~SourceReaderCB terminating async callback
```

`speed=0.999x` と、ほぼ1.0でリアルタイムな時間になっているらしいことがわかります。

# 参考文献
- [FFmpeg 2 Format Options → `use_wallclock_as_timestamps`](https://ffmpeg.org/ffmpeg-formats.html#toc-Format-Options)
- [FFmpeg 5.11 Advanced options → `-vsync`](https://ffmpeg.org/ffmpeg.html#toc-Advanced-options)
- [ffmpeg-python documentation](https://kkroening.github.io/ffmpeg-python/)