例題17-2:Webカメラの動画を保存する

A: 冷えてきたな。ストーブでも入れるか。さて例題17-1の続きだ。

B: あ、やっとストーブ買ったんですね。この実験室冬は寒くて。エアコンつけても効きゃしない。

A: どうみても部屋の広さにエアコンの出力が見合ってないしな。部屋出るときはちゃんと消していってくれよ。

B: へーい。

A: さて、今回はwebカメラの動画の保存をするぞ。まあ、正直微妙な出来なんだが…

B: どう微妙なんですか?

A: まあ見てりゃわかる。

B: 微妙なんだったらもうちょっとあれこれ試してからにしたらいいのに。

A: あいにく今は試行錯誤するためのまとまった時間がないんだ。もう「これは後で」みたいな業務が山積みでな。消せるものからさっさと消してすっきりしたい。

B: ということは、今回の例題の事はぱあっと吐き出したらもうフォローしないってことですか?

A: 頑張っても自分がもともと考えていた実験に必要な水準には達しそうにないんだ。だからこれ以上は深入りしない。少なくとも今は。

B: ふうん。なんだか知りませんけど大変ですねえ。

A: そうそう、 このサンプルではffmpegを使用するので、ffmpegの実行ファイルを入手してパスが通った場所かこのサンプルを実行するディレクトリに置いといてください。それからWindowsで動かすことを前提にしているので時間の計測にtime.clock()を使っています。

  • 行番号なしのソースファイルをダウンロード→ 17-2.py

 1import time
 2import VideoCapture
 3import numpy
 4import Image
 5import pygame
 6import subprocess
 7from pygame.locals import *
 8
 9cam = VideoCapture.Device()
10cam.displayCapturePinProperties()
11cmdstring = ('ffmpeg.exe', 
12             '-y', 
13             '-r', '30', 
14             '-f','image2pipe', 
15             '-vcodec', 'mjpeg', 
16             '-i', 'pipe:',  
17             '-vcodec', 'mpeg1video', 
18             '-vb', '3000k',
19             '-intra',
20             'test.mpg'
21             ) 
22p = subprocess.Popen(cmdstring, stdin=subprocess.PIPE, shell=False) 
23
24pygame.init()
25screen = pygame.display.set_mode((640,480))
26screen.fill((0,0,0))
27fp = open('log.csv','w')
28
29flg = True
30nframes = 0
31ticks = []
32loopStartTime = time.clock()
33while flg:
34    img = cam.getImage()
35    pimg = pygame.image.fromstring(img.tostring(), img.size, "RGB")
36    screen.blit(pimg, (0, 0))
37    pygame.display.flip() 
38    nframes += 1;
39    
40    for event in pygame.event.get():
41        if event.type == QUIT:
42            flg = False
43        elif event.type == KEYDOWN and event.key == K_ESCAPE:
44            flg = False
45    
46    p.stdin.write(img.tostring('jpeg','RGB'))
47    ticks.append(time.clock()-loopStartTime)
48
49p.stdin.close()
50loopTime = time.clock()-loopStartTime
51fp.write('%f seconds,%d frames,frames/30:%f\n'% (loopTime, nframes, nframes/30.0))
52for tick in ticks:
53    fp.write('%f\n' % (tick))
54fp.close()

A: まあまずは動かしてみたまえ。

B: お、ダイアログアでてきましたよ。解像度とか設定できるんですねえ、へえ。

../_images/17-2-01.png

A: これが前回ちょっと説明したdisplayCapturePinPropertiesメソッド、10行に出てくるやつね。一応今回のサンプルは640x480、30Hzで動かすことを前提にしているので、もしそうなっていなければ調整してほしい。

B: 調整できなかったら?

A: んーっと。51行目とかに30Hzを決め打ちで書いている行があるのでその辺をちょいちょいっと修正してください。先に進んで。

B: 前回と同じようなカメラ画面が出てきましたね。…あれっ、今一瞬止まりましたが。

A: それが今回のプログラムで微妙な点その1ね。適当なところでESCキーを押して終了して。

B: はい、終了しました。

A: サンプルスクリプトを実行したディレクトリにtest.mpgというファイルが出来てるだろ。それを再生してみて。

B: おお、確かに今撮った動画だ。かなり画質悪いですけど。それにちょっとゆっくりしている?

A: 画質を上げるとPCへの負担が大きくなるんで難しいところだが、18行目の'3000k'をもっと大きな値にしたら画質は向上する。

B: 3000はともかく最後の"k"はなんですか?

A: kiloのkだな。1000。kmとかkgとかのkと同じ。

B: あ、はいはい。でもこの18行目の前後のあたりがさっぱりわからないんですが…。

A: それが今回のポイント。今回のサンプルでは、pythonの中で動画を圧縮して保存するのを諦めてffmpegに処理を任せている。6行目でimportしているsubprocessというモジュールは、pythonを実行したまま指定したプログラムを実行させるものだ。しかも、ただ実行させるだけじゃなくてそのプロセスの標準入力にアクセスしたりなんかできる。

B: 標準入力?

A: あー、標準入力って言ってもわからんか。まあこの辺はCUIの基礎の基礎なんで詳しく説明する気はないんだが、要するにプログラムに文字列を送ったり、プログラムから文字列を受け取ったりすることが出来るんだ。

B: うーん、よくわからなければ何を勉強すりゃいいですか?

A: 普通に「標準入力」を検索すればいくらでも親切なページが出てくると思う。11行目から21行目のタプルはsubprocessでcmd.exeに渡す文字列を指定している。ここの文字列の意味はffmpegのオプションを調べてもらえばだいたいわかるはず。以上。

B: わかるはずって、せめてポイントくらい解説してくださいよ。

A: むー。実は、この方法は検索で見つけた この記事 をそっくり参考にさせていただいたんだな。標準入力からJPEG形式でじゃんじゃんデータを送って、Moion JPEGにして、そいつをさらにMPEG2にして保存していると理解しているが自分でもよくわかってない。この辺も心残りだがそこは 動きゃいいんだよ、動きゃ! の精神で目をつむっている。

B: うーん。そうですか。

A: 多分一瞬カクっとなるのもオプションを適切に指定したら何とかなるのだと思う。けど私は自分の目的ではこれ以上踏み込む意義がなかったんで、読者の皆さんでこの「カクッ」が気になる人がいたらぜひffmpegをきちんと調べて欲しい。

B: …。

A: さて、準備したffmpegのコマンドを22行目のsubprocess.Popen()で起動。後は46行目にあるようにPopenの標準入力にガンガン画像データを書き込んでいけば、勝手にffmpegがtest.mpgという動画ファイルを作ってくれる。終了したら49行目のようにclose()。

B: よくわからないところだらけですが、書かないといけない分量は少しで済みそうですね。保存ファイル名を変更したい場合は20行目を変更するんですね?

A: その通り。さて、log.csvという意味ありげなファイルに何か書き込んでいるのに気付いていると思うが、ここに動画がゆっくり再生されてしまう理由が隠れている。

B: どれどれ。開きましたよ。

29.338553 seconds,1124 frames,frames/30:37.466667
0.321307
0.344225
0.365391
0.386257
0.407175
0.428314
(以下略)

A: log.csvの一行目の最初の数値は録画していた時間、2番目は書き込まれたフレーム数、最後はフレーム数を30で割ったもの。30fpsで記録しているんだからフレーム数を30で割ったら動画の再生時間になるはずで、実際再生してみたらこの通りになるんだが、29.3秒しか録画していなかったのに37.5秒も再生時間があるなんて事態になってしまっている。これがさっきB君が「ゆっくり再生されている」と感じた理由。

B: なんでこんなことに?

A: 1秒間に30フレーム以上書き込んでしまっているからだよ。それがわかるのが2行目以降。フレームが書き込まれた時刻をずらっと出力しているんだが、わかりやすいように差分をプロットしてみよう。一個ポーンと飛び出した値があるので縦軸を調節したのが右のグラフ。

../_images/17-2-02.png

B: おや、Excelのグラフとは久しぶりですねえ。それにしてもこの飛び出しているのはひどいですね。

A: 一瞬カクッとなったところだね。縦軸の単位は秒なので0.6秒弱かかっているってこった。で、右のグラフだが、0.025秒から0.03秒くらいの間を行ったり来たりしていることがわかるね。30fpsだとフレーム間隔は0.0333...秒だから、ちょっと書き込みの間隔が短すぎる。

B: どうしたらちゃんと30fpsで書けるんですか。

A: まあちょっと待て。この問題は、34行目でgetImage()した時に、戻ってきた画像が直前に取得した画像と同じかどうかを判定できていないところに原因がある。ちゃんとしたAPIが備わっているカメラなら、まずカメラに前回取得してから新しい画像が撮影されたかを問い合わせる機能があるんだが、どうもVideoCaptureはそういう使い方を想定していないようでこの機能がない。OpenCVにはあるっぽい記述をリファレンスで読んだ気がするんだが、そもそもウチのカメラではまともに動かないので使えない。だから、何とか工夫をして乗り越える必要がある。

B: 工夫?

A: カメラがちゃんと30fpsで撮れているんなら、1秒間に30回、等しい時間間隔でカメラ画像を取得すればいいわけだ。

B: <あーっ、わかりました。前回画像を取得した時刻を保存しておいて、1/30秒経ったらまた取得すればいいんでしょ!

A: ぶぶーっ。理屈上はそれでうまくいきそうな気がするが、残念ながらそれではだめ。PC上では他のプログラムが並行して動いているので、そっちに処理を食われると今度は取りこぼしが起きる。今回は特に裏でffmpegという結構パワーを食うプログラムを走らせているのでなおさらの事。

B: んー。じゃあどうしたらいいんですか。

A: PCには30fpsを測るのにちょうど都合がいいイベントがあるだろ。すでにこの講座でも出てきているぞ?

B: ???

A: 垂直同期だよ。VisionEgg.Core.swap_buffers。垂直同期の設定が60Hzだったら、swap_buffersを2回実行する毎に1回書き込めばちょうど30fpsになるはずだ。

B: ああ、なるほど!

A: 要は垂直同期を待てばいいので別にpygameのままでも書けるんだが、ここまで考えた時点でVisionEggをインストールしたマシンへ作業現場を移したので、VisionEggを使ったらこんな感じだよってなサンプルを示す意味も込めてVisionEggで書いた。これがそのサンプルだ。

  • 行番号なしのソースファイルをダウンロード→ 17-3.py

 1import VideoCapture
 2import numpy
 3import Image
 4import pygame
 5import subprocess
 6import VisionEgg
 7import VisionEgg.Core
 8import VisionEgg.Text
 9from pygame.locals import *
10
11cam = VideoCapture.Device()
12cam.displayCapturePinProperties()
13cmdstring = ('ffmpeg.exe', 
14             '-y', 
15             '-r', '30', 
16             '-f','image2pipe', 
17             '-vcodec', 'mjpeg', 
18             '-i', 'pipe:',  
19             '-vcodec', 'mpeg1video', 
20             '-vb', '3000k',
21             '-intra',
22             'test.mpg' 
23             ) 
24p = subprocess.Popen(cmdstring, stdin=subprocess.PIPE, shell=False) 
25fp = open('log.csv','w')
26
27screen = VisionEgg.Core.get_default_screen()
28msg = VisionEgg.Text.Text(position=(screen.size[0]/2,screen.size[1]/2),anchor='center')
29viewport = VisionEgg.Core.Viewport(screen=screen,stimuli=[msg])
30
31flg = True
32nframes = 0
33ncapframes = 0
34ticks = []
35loopStartTime = VisionEgg.time_func()
36while flg:
37    
38    for event in pygame.event.get():
39        if event.type == QUIT:
40            flg = False
41        elif event.type == KEYDOWN and event.key == K_ESCAPE:
42            flg = False
43    
44    msg.parameters.text=str(nframes)
45    
46    screen.clear()
47    viewport.draw()
48    VisionEgg.Core.swap_buffers()
49    nframes += 1
50    
51    if nframes % 2 == 0:
52        img = cam.getImage()
53        p.stdin.write(img.tostring('jpeg','RGB'))
54        ticks.append(VisionEgg.time_func()-loopStartTime)
55        ncapframes += 1
56
57p.stdin.close()
58loopTime = VisionEgg.time_func()-loopStartTime
59fp.write('%f:seconds,%d:frames,%d:capframes,frames/30:%f,capframes/30:%f\n'% (loopTime, nframes, ncapframes, nframes/30.0, ncapframes/30.0))
60for tick in ticks:
61    fp.write('%f\n' % (tick))
62fp.close()
63

B: んー。VisionEggになっている以外にも結構変わっていますね。画面に現在何フレーム目を表示しているか描画していますか?

A: その通り。で、なんでそんなものを表示するかというと、このVisionEggの画面をwebカメラで撮影してやろうというわけだ。期待通りに動作していれば、1フレーム進むごとに2ずつ値が増えていくはず。

B: おお、そりゃ面白そうですね。でもちょっと待ってくださいよ? 値が2ずつ増えるというのは?

A: 51行目からの処理をよく見たまえ。48行目でswap_buffers()を実行して49行目でフレーム番号を1増加させた後、フレーム番号を2で割った余りが0か否かを判定している。2フレーム毎にこのif文は真になるから、2フレームに1回52行目からの処理が実行される。

B: ふむふむ。

A: 例題 11-111-2 で見たとおり、swap_buffers()は垂直同期を待つので、swap_buffers()を実行した直後に判定すればかなり正確に時刻を測ることが出来る。垂直同期がカメラのfpsの整数倍に等しいという条件付きだが、特別なハードウェアやライブラリを使わずにすむのはかなり美味しい。例題11で反応時間を測る時には大きな障壁となった垂直同期がここでは私たちを助けてくれるというわけだ。

B: ははー。なんだか漫画か何かでありそうな話ですな。かつてのライバルが助けてくれるみたいな。

A: log.csvには17-2.pyの出力に加えて実際に出力したフレーム数(ncapframes)や、それを30で割った値なども出力しているので見ておいてほしい。

B: どれどれ…。あれ、2行目以降の時刻が正確に33.3ms間隔ではないようですが。

A: そりゃマルチタスクOSなんだから仕方がないな。そもそも2行目以降に出力されている値は54行目でVisionEgg.timefunc()を実行した時の時刻を記録しているだけであって、カメラ画像自体はカメラが規則正しいタイミングで撮影している。だからカメラ画像が等しい時間間隔で取得されているか否かはカメラの性能のかかっているのであって、2行目以降の出力はコマ落ちや重複出力がないかを確認するだけのものである。

B: う、いまいち意味が…。

A: こういうことだよ。取得するタイミングが不定期でも、カメラ側での撮影と撮影の間に1回ずつ取得されていれば、正しく30fpsの動画が記録できている。もし撮影と撮影の間に画像取得をあらわす上向きの矢印が1本もなければコマ落ち、2本以上あれば同じコマを重複して記録していることになる。

../_images/17-2-03.png

B: ははあ、わかったような、わからないような…。

A: ともかく、2フレームに1回カメラ画像を保存しているわけだから、期待通りに動作していれば作製された動画ファイルを1コマ進めると映っているフレーム番号は2進むはず、というのはわかったかい?

B: んー。自信ないけどわかったことにします。

A: 動画ファイルを再生してみればわかるわけだが…さて、再生、と。どうだろう。Bくん、わかる?

B: 速すぎて全然わかんないですよ!

A: だよな。まあコマ送りが出来るプレイヤーでぽちぽち見ていってもいいわけだが、やってらんないのでプログラムで出力してみよう。例題16-5でやったようにフレーム番号をバーコード出力してプログラムで自動判定すりゃいいんだが、そこまでやる気がなかったので手でwebカメラを持って撮影した動画から100フレーム毎に画像ファイルに出力して目視で確認してみた。100フレーム毎に画像ファイルに出力するプログラムは以下の通り。ウィンドウのサイズとか決め打ちなんで、640x480の30fps以外で試している人は各自で修正してくださいね。

  • 行番号なしのソースファイルをダウンロード→ 17-4.py

 1import pygame.movie
 2import pygame
 3import sys
 4from pygame.locals import *
 5
 6pygame.init()
 7screen = pygame.display.set_mode((640,480))
 8
 9movie = pygame.movie.Movie(sys.argv[1])
10surface = pygame.surface.Surface(movie.get_size())
11movie.set_display(surface)
12
13movie.play()
14
15flg=True
16while flg:
17    screen.blit(surface,(0,0))
18    pygame.display.flip()
19    frame = movie.get_frame()
20    
21    if frame % 100 == 0:
22        pygame.image.save(screen,sys.argv[1]+'%04d.jpg' % frame)
23    
24    for event in pygame.event.get():
25        if event.type == QUIT:
26            flg = False
27        elif event.type == KEYDOWN and event.key == K_ESCAPE:
28            flg = False

A: 例題16-5がわかっていれば特にプログラムに難しいところはないはず。21行目でフレーム番号が100で割り切れるか判定して、割り切れたらpygame.image.saveで画面を保存する。

B: あれ? 9行目のsys.argvってなんでしたっけ?

A: ああ、17-4.pyはcmd.exeのようなコマンドシェルから起動して使用する。例えば"17-4.py test.mpg"とコマンドを打つと、sys.argvにコマンドライン引数が格納される。sys.argv[0]はOSによってちょっと違うんだが要はコマンド名そのもの、sys.argv[1]から1つめの引数に対応している。

B: ダイアログでちょいちょいと選べないんですか。

A: すでにtkFileDialogを教えただろ、自分で書き換えなさい。

B: うへっ、藪蛇藪蛇。

A: さて、実験室に転がっていた3種類のカメラで撮影した結果を並べてみたぞ。100フレーム目から500フレーム目までを示してある。手持ち撮影でブレブレなうえに数字をかなり小さくしてしまったんで拡大している分画質も悪くなってしまっているが、まあ読めることは読めるだろう。

../_images/17-2-04.png

カメラA

../_images/17-2-05.png

カメラB

../_images/17-2-06.png

カメラC

B: うーん、1の位はかなり判別が厳しい画像もありますね。

A: 仕方ないな。もともとwebカメラ自体こんな用途を想定してないだろうからシャッタースピードもそれほど速くないだろうし、ディスプレイもLCDを使っているので画面自体が描き換えの速度についていけていない可能性もある。そうそう、ちなみにこの画像の撮影に使ったPCはCore i7 920、Geforce GTX550Tiを積んだWindows7(x64)。Pythonは32bitの2.7を入れてある。

B: カメラAとBは200ずつ進んでいるように見えますね。ちょっと怪しいのもあるけど。カメラCは1の位が3から5の間でばらついているような…?

A: カメラAも同じようなもんだと思うがな。まあこれをどう見るかはそれぞれだろうけど、私の感想は思っていたよりは正確だなあってところだな。長時間走らせた時の安定性はもう少し慎重に評価する必要があるけど、そこそこの正確さで十分な用途には使えるだろう。

B: その「そこそこ」っていうのがよくわからないなあ。Aさんの言ってることって「使える用途には使える」っていうだけのような気がする。そんなのトー…、ト、なんだっけ。トローチ?

A: もしかしてトートロジーと言いたいのか。

B: あ、そんな感じ。

A: まあその辺は明確な目的を意識していないと判断できないだろうなあ。私は自分ならあんなことやそんなことに使うなあというのをイメージして、それに必要な性能を満たしているかどうかで考えてるから。

B: ん? そのAさん基準で「まあまあ使える」んですよね。でもさっき「使えそうにないからあきらめた」みたいなことを言っていませんでしたか?

A: ほう。よく覚えてるな。私がまあまあ使えるといったのは時間的な精度の話。使えないと思ったのは主に画質の問題だ。サンプルプログラムは5000kbpsくらいまでビットレートを上げればもう少しましな動画を撮れるけど、シャッタースピードとか感度とかの面で私が想定していた用途に使うのは厳しすぎる。

B: その辺がAさんの眼鏡にかなうwebカメラはないんですか?

A: んー。6機種ほど試したけどどれもダメだなあ。いや、ダメっていうか、webカメラはそういう用途に使うものじゃないんだよ。私が求めているものが無茶なんだ。

B: へえ、具体的には何に使うつもりなんですか?

A: それはないしょ。

B: えー。

A: 実現できた暁にはB君にも実験に参加してもらうかもしれないからな。言うわけにはいかんな。

B: ぶーぶー。

A: さて、ここでおしまいにしてもいいんだが、最後に補足。さっき書いたように今回のサンプルはCore i7 920を積んだPCでテストしたんだが、ffmpegの処理がかなり重いみたいで性能が低いPCでは処理落ちしてカクカクの動画になる恐れがある。

B: 性能が低いPCって安いネットブックみたいなやつですか。

A: いや、それがだな、研究室にあったCore i7 860を積んだPCでもダメだったんだよ。i7の860って言ったらそこそこのクラスのCPUで、こいつで厳しいとなるとここ数年以内に販売されたハイパフォーマンス機じゃないとついていけない恐れがある。

B: ああ、じゃあウチのボロPCじゃだめだ。

A: 正直なところ860でダメというのはちょっと納得がいかないので、もしかしたら他に原因があるかも知れない。

B: 圧縮の処理が大変なんですよね。圧縮せずに保存しちゃダメなんですか?

A: 無圧縮か。それは確かに負荷が少ないが悲惨なことになるぞ。

B: 悲惨?

A: ファイルサイズがとんでもないことになる。試しに640x480、30fpsの動画を60秒無圧縮で撮影したら1.54GBになった。

B: メガじゃなくてギガ?!

A: メガなわけないだろ。動画てのはそれだけデータがでかいんだよ。動画圧縮技術の進歩がなければ今みたいに個人が動画撮影や編集、配信を楽しむなんてことはとても出来なかっただろうよ。

B: うーん。ちょっと驚きました。

A: さて、これで今回のwebカメラいじりの成果はあらかた吐き出したかな。OpenCVがらみのネタもあるんだが、それはまた機会があればにしよう。せっかくだから最後に遊んでおくか。

B: 遊ぶ?

A: そ。VideoCaptureは複数のwebカメラが接続していたらそれぞれから同時に画像を取得することが出来るので、お気楽に複数台のカメラ画像を合成した動画を作成することが出来る。刺激画面も一緒に合成することが出来るんで、赤ちゃん相手の実験みたいに参加者の様子をビデオ撮影して後から刺激と比較するような用途には便利なんじゃないかな。

B: へえ、そんなことが出来るんですか。

A: 画質の壁が立ちはだかるが、な。まあ楽しみにしておきたまえ。