PyAVで動画の任意のフレームを取得する

# PyAVとは？

* pythonで動画を高速に呼び出すモジュール．バックエンドにffmpeg（libavformatなど）を使用している
* Meta（Facebook）が公開している[pytorchvideo](https://pytorchvideo.org/)でも内部的に用いられている

PyAVに関して説明されている日本語の記事が少なく，また動画内の任意のフレームを取得する方法が[公式ドキュメント](https://pyav.org/docs/stable/#)では分かりづらかったため記事を書いてみました．

- [公式ドキュメント](https://pyav.org/docs/stable/#)
- [github](https://github.com/PyAV-Org/PyAV)


# 記事の内容

* PyAVを使うモチベーション
* ライブラリのインポート
* PyAVで動画の情報を取得する
* PyAVの基本的な使い方
* 指定した秒数における動画のフレームを取得する

# PyAVを使うモチベーション

pythonで動画のフレームを取得するためにはopencvを使うことが多いのですが，

- 取得したフレームの時刻が分からない（フレーム番号は分かるのでfpsから逆計算）
- オーディオは取得できない

という問題があります．



そこでPyAVを用いれば，高速に動画を読み込み，かつ様々な情報も取得することが可能です．（オーディオの読み込みはここでは説明しませんが）



# ライブラリのインストール

pipを使うなら以下のコマンドでインストールします．

```bash:
pip install av
```

# PyAVで動画の情報を取得する

## まずは動画を取得してみる

```python:最低限のコード
import av

container = av.open("zlVkeKC6Ha8.mp4")
```

上のように取得したい動画のパス（ここでは`zlVkeKC6Ha8.mp4`が動画ファイル名）を引数とします．

（ちなみにこの動画は[AVA dataset](https://research.google.com/ava/download.html#ava_actions_download)の一つ）



## 動画の情報を取得してみる

fpsと動画のサイズを取得してみます．

```python:main.py
import av

container = av.open("zlVkeKC6Ha8.mp4")
stream = container.streams.video[0]  # 0番目のvideoストリームを選択

print(stream.rate)  # fps
print(stream.width)
print(stream.height)
```

以下のように動画の情報が取得できることが確認できます．

```bash:実行結果
$ python ./main.py
30
640
480
```

他にも様々な情報が取得できます．
どんな情報が取得できるかは[公式ドキュメント](https://pyav.org/docs/develop/api/video.html?highlight=videostream)を参照ください．


# PyAVの基本的な使い方

PyAVの基本的な使い方は以下のようなものが公式ドキュメントでは紹介されています．

```python:0番目のvideo streamからフレームを取得するループ
import av

container = av.open("zlVkeKC6Ha8.mp4")
for frame in container.decode(video=0):
    # フレーム毎の処理など
```

上のようにジェネレータから取り出された`frame`は`VideoFrame`クラスであり，`time`属性で，そのフレームが動画の何秒目のものかを取得することができます．

以下のコードで確認してみます．

```python:main.py
import av

container = av.open("zlVkeKC6Ha8.mp4")
for i, frame in enumerate(container.decode(video=0)):
    print(f"i:{i}, time:{frame.time}")
    if i > 5:
        break
```

すると結果は以下のようになります．

```bash:実行結果
$ python main.py
i:0, time:0.0
i:1, time:0.03333333333333333
i:2, time:0.06666666666666667
i:3, time:0.1
i:4, time:0.13333333333333333
i:5, time:0.16666666666666666
i:6, time:0.2
```


この結果から，1フレーム進むと1/30秒進んでいることが確認できます．[動画の情報を取得してみる](#動画の情報を取得してみる)でfpsが30であることを確認しているので，納得の結果です．

ただしこのままでは動画の任意の時間のフレームが取得できません．次のセクションでその方法を紹介します．


# 指定した秒数にシークして動画のフレームを取得する

`seek`を用いることで任意の秒数まで進むことが出来ます．
2通りの方法が確認できたので順番に紹介します．

## `stream`を用いる方法

[動画の情報を取得してみる](#動画の情報を取得してみる)で登場した`stream`が再登場です．
以下のコードと実行結果を確認してください．

```python:main.py
import av

container = av.open("zlVkeKC6Ha8.mp4")
stream = container.streams.video[0]

sec = 400
container.seek(
    offset=sec // stream.time_base,
    any_frame=False,
    backward=True,
    stream=stream)

for i, frame in enumerate(container.decode(video=0)):
    print(f"i:{i}, time:{frame.time}")
    if i == 6:
        break

print(stream.time_base)
```

以下は実行結果

```bash:実行結果
$ python main.py
i:0, time:400.0
i:1, time:400.03333333333336
i:2, time:400.06666666666666
i:3, time:400.1
i:4, time:400.1333333333333
i:5, time:400.1666666666667
i:6, time:400.2
1/90000
```


確かに400秒目のフレームから取得できていることが確認できます．

以下で`seek`の引数について説明します．

- `offset`：
  - 後述する引数の`stream`が与えられた場合は`stream`の`time_base`を与えます．具体的には上のコードのように取得したい秒数を`stream.time_base`で割ればOKです．
    - この`time_base`は`Fraction`型で，1/90000が入っています．秒`sec`をこれで割ることで`offset`が得られます．
  - `stream`が与えられなかった場合については後述します

* `any_frame`：
  - これはキーフレームだけでなくすべてのフレームについて探すかどうかを指定します．
  - キーフレームというのはmpeg動画におけるIフレームのことです．動画にはそのフレーム全ての情報を持っているIフレームと情報圧縮のためIフレームから予測されたフレーム（PフレームとBフレーム）があるのが一般的ですが，キーフレームとはこのIフレームのことです．
  - デフォルト値は`False`になっています．

* `backward`：
  - `offset`で指定したフレームがIフレームでなかった場合，直近のIフレームまで戻るかどうか指定します．
  - デフォルト値は`True`になっています．

* `stream`
  - オフセットで指定するタイムベースのストリームを与えます．
  - デフォルト値は`None`となっており，streamを与えない場合の方法は以下で紹介します．

## `stream`を用いない方法

`stream`を用いない場合，オフセットで指定するタイムベースはint型の`av.time_base`となります．これは分数ではないため，
`seek`の`offset`計算は以下のように除算から乗算に変更すればOKです．

```python:
import av

container = av.open("zlVkeKC6Ha8.mp4")
stream = container.streams.video[0]

sec = 400
container.seek(
    offset=sec * av.time_base,
    any_frame=True,
    backward=False)

for i, frame in enumerate(container.decode(video=0)):
    print(f"i:{i}, time:{frame.time}")
    if i == 6:
        break

print(av.time_base)
```

以下は実行結果

```bash:実行結果
i:0, time:400.0
i:1, time:400.03333333333336
i:2, time:400.06666666666666
i:3, time:400.1
i:4, time:400.1333333333333
i:5, time:400.1666666666667
i:6, time:400.2
1000000
```


# バグ？

`seek`を使用している際に正しくシークできない場合があることに気づいたので，ログを残しておきます.

バグの内容としてはoffsetで指定した秒数とは少しずれた位置のフレームが取り出されてしまうということです．

以下のコードのように取り出されたフレームがキーフレームであるかどうかを`frame.key_frame`で確認したところすべて最初のフレームがキーフレームとなっていたのでどうやら引数の`any_frame`が上手く働いていないようです．

```python:
import av

container = av.open("zlVkeKC6Ha8.mp4")
stream = container.streams.video[0]

sec = 500
container.seek(
    offset=sec // stream.time_base,
    any_frame=True,
    backward=False,
    stream=stream)

for i, frame in enumerate(container.decode(video=0)):
    print(f"i:{i}, time:{frame.time}, key_frame:{frame.key_frame}")
    if i > 5:
        break
```

以下は実行結果


```bash:実行結果
i:0, time:501.3333333333333, key_frame:1 
i:1, time:501.3666666666667, key_frame:0
i:2, time:501.4, key_frame:0
i:3, time:501.43333333333334, key_frame:0
i:4, time:501.46666666666664, key_frame:0
i:5, time:501.5, key_frame:0
i:6, time:501.53333333333336, key_frame:0
```


## 指定のフレームが取得したい場合

秒指定ではなくフレーム番号を指定して取得したい場合はopencvや[pims](https://github.com/soft-matter/pims)が使えます．

### pims

pimsなら以下のようにフレームが取得できます．フレームにリスト形式でアクセスできますが，シークは遅いです．（ちなみに15分の動画の最後のフレームを取得するのに約30秒ほどかかりました）

```python:picsの場合
import pims

v = pims.Video('zlVkeKC6Ha8.mp4')
v[10]  # 10番目のフレームのnumpy配列
```

### opencv

opencvでフレーム番号を指定するなら以下の通り．シークはpimsに比べて高速です．

```python:opencvの場合
import cv2

cap = cv2.VideoCapture('zlVkeKC6Ha8.mp4')
cap.set(cv2.CAP_PROP_POS_FRAMES, 10)
ret, frame = cap.read()
```

opencvでfpsから逆算して秒指定でフレームを取得するなら以下の通り．

```python:opencvのサンプル
import cv2

cap = cv2.VideoCapture('zlVkeKC6Ha8.mp4')

sec = 500
fps = cap.get(cv2.CAP_PROP_FPS)
cap.set(cv2.CAP_PROP_POS_FRAMES, sec * fps)  # seek

for i in range(7):
    print(f"i:{i}, time:{cap.get(cv2.CAP_PROP_POS_FRAMES) / fps}")  # 現在フレームの時刻を取得
    ret, frame = cap.read()
```

表示上は正確のようです．

```bash:実行結果
i:0, time:500.0
i:1, time:500.03333333333336
i:2, time:500.06666666666666
i:3, time:500.1
i:4, time:500.1333333333333
i:5, time:500.1666666666667
i:6, time:500.2
```




# 参考
* [キーフレームとは？Iフレーム･Pフレーム･Bフレームの違い【GOP】](https://aviutl.info/keyframe/)