LoginSignup
6
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-20

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と動画のサイズを取得してみます.

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)

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

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

他にも様々な情報が取得できます.
どんな情報が取得できるかは公式ドキュメントを参照ください.

PyAVの基本的な使い方

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

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

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

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

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

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

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

実行結果
$ 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が再登場です.
以下のコードと実行結果を確認してください.

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)

以下は実行結果

実行結果
$ 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が与えられた場合はstreamtime_baseを与えます.具体的には上のコードのように取得したい秒数をstream.time_baseで割ればOKです.
      • このtime_baseFraction型で,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となります.これは分数ではないため,
seekoffset計算は以下のように除算から乗算に変更すれば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秒ほどかかりました)

picsの場合
import pims

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

opencv

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

opencvの場合
import cv2

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

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

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()

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

実行結果
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

参考

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2