PyAVとは?
- pythonで動画を高速に呼び出すモジュール.バックエンドにffmpeg(libavformatなど)を使用している
- Meta(Facebook)が公開しているpytorchvideoでも内部的に用いられている
PyAVに関して説明されている日本語の記事が少なく,また動画内の任意のフレームを取得する方法が公式ドキュメントでは分かりづらかったため記事を書いてみました.
記事の内容
- PyAVを使うモチベーション
- ライブラリのインポート
- PyAVで動画の情報を取得する
- PyAVの基本的な使い方
- 指定した秒数における動画のフレームを取得する
PyAVを使うモチベーション
pythonで動画のフレームを取得するためにはopencvを使うことが多いのですが,
- 取得したフレームの時刻が分からない(フレーム番号は分かるのでfpsから逆計算)
- オーディオは取得できない
という問題があります.
そこでPyAVを用いれば,高速に動画を読み込み,かつ様々な情報も取得することが可能です.(オーディオの読み込みはここでは説明しませんが)
ライブラリのインストール
pipを使うなら以下のコマンドでインストールします.
pip install av
PyAVで動画の情報を取得する
まずは動画を取得してみる
import av
container = av.open("zlVkeKC6Ha8.mp4")
上のように取得したい動画のパス(ここではzlVkeKC6Ha8.mp4
が動画ファイル名)を引数とします.
(ちなみにこの動画はAVA datasetの一つ)
動画の情報を取得してみる
fpsと動画のサイズを取得してみます.
import av
container = av.open("zlVkeKC6Ha8.mp4")
stream = container.streams.video[0] # 0番目のvideoストリームを選択
print(stream.rate) # fps
print(stream.width)
print(stream.height)
以下のように動画の情報が取得できることが確認できます.
$ python ./main.py
30
640
480
他にも様々な情報が取得できます.
どんな情報が取得できるかは公式ドキュメントを参照ください.
PyAVの基本的な使い方
PyAVの基本的な使い方は以下のようなものが公式ドキュメントでは紹介されています.
import av
container = av.open("zlVkeKC6Ha8.mp4")
for frame in container.decode(video=0):
# フレーム毎の処理など
上のようにジェネレータから取り出されたframe
はVideoFrame
クラスであり,time
属性で,そのフレームが動画の何秒目のものかを取得することができます.
以下のコードで確認してみます.
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
すると結果は以下のようになります.
$ 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
が再登場です.
以下のコードと実行結果を確認してください.
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)
以下は実行結果
$ 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です.
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)
以下は実行結果
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
が上手く働いていないようです.
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
以下は実行結果
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が使えます.
pims
pimsなら以下のようにフレームが取得できます.フレームにリスト形式でアクセスできますが,シークは遅いです.(ちなみに15分の動画の最後のフレームを取得するのに約30秒ほどかかりました)
import pims
v = pims.Video('zlVkeKC6Ha8.mp4')
v[10] # 10番目のフレームのnumpy配列
opencv
opencvでフレーム番号を指定するなら以下の通り.シークはpimsに比べて高速です.
import cv2
cap = cv2.VideoCapture('zlVkeKC6Ha8.mp4')
cap.set(cv2.CAP_PROP_POS_FRAMES, 10)
ret, frame = cap.read()
opencvでfpsから逆算して秒指定でフレームを取得するなら以下の通り.
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()
表示上は正確のようです.
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