pythonでできるだけ精度の良いキャプチャーソフトを作ってみる（２）


pythonでできるだけ精度の良いキャプチャーソフトを作ってみる（１）
https://qiita.com/akaiteto/items/b2119260d732bb189c87

ざっくり目標を言えば、
pythonでアマレココみたいなソフト作って、
カスタマイズ性の高い録画録音しようぜ、
という目的です。

前回はpythonでの画像のキャプチャ方法と、
システム音のキャプチャ方法の各パーツの基本的な使い方を検討しました。

今回は画面キャプチャの精度向上を目標に検討してみます。


#画像キャプチャ
##はじめに

前回は画面キャプチャをする処理が遅いという話が出ました。
全体で18fpsという遅い処理速度です。これだと動画にしたらちょっとカクつきそうです。
28以上は個人的に欲しいところです。


全体の処理として、

```
1.画像のキャプチャ
2.画像のカラーフォーマットをRGBに変換
```

という２つのステップを考えたとき、
前回の検討では１のステップの改善を行いました。

具体的には、解像度1920×1080で処理を測定したとき、
従来(ImageGrab.grab)が26fpsだったのを、
win32を導入したことでfps42になりました。

続けて2の変換処理の改善を検討します。
・・・と、その前にそもそも、速度を追求する必要があるのかを考えます。

リアルタイム性が必要ないのであれば、
配列なりjpgファイルなりにデータを保持しておいて、
あとから変換処理を行うでも問題ないように思います。

ですが、ここではあえてリアルタイム性を目標にしてみます。
長時間録画することを考えると、何も考えずにデータを保持し続けたら
メモリ圧迫しそうですし、メリットはあるかもしれません。

ということで、変換処理の処理速度について掘り下げます。

##画面キャプチャ・変換処理の比較

前回使用したPillowのImageGrab.grabは、BGRの形式で画像が出力されます。
動画を保存する際にはRGBの画像である必要があるので、
どうしてもOpenCVの変換が必要になり、そのせいで処理速度が遅くなりました。

そして今回。
win32のapiで出力された画像はRGBA。
動画として保存するにはRGBに変換が必要です。

前回はキャプチャまでの速度を図りましたが、
今度はキャプチャして変換するまでの速度を比較しましょう。

```
#従来 ImageGrab.grab
parentTime = time.time()
for i in range(40):
    img_cv = np.asarray(ImageGrab.grab())
    img = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)
current = time.time()
diff = (current - parentTime)
print("fps:" + str(float(40)/diff))

#改善 win32+opencv
parentTime = time.time()
for i in range(40):
    memdc.BitBlt((0, 0), (width, height), srcdc, (0, 0), win32con.SRCCOPY)
    img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
current = time.time()
diff = (current - parentTime)
print("fps:" + str(float(40)/diff))

```

```
fps:17.665207097327016
fps:29.761997556556736
```

あ、opencvの変換処理でもwin32は十分早いですね。
一旦これで様子を見ましょう。


##動画の出力
たいして進捗は進んでいませんが、ここまでのコードを書いてみます。
（全く整理されていない＆問題のあるコードなので使わないほうが良いです）

```
import cv2
import numpy as np
from PIL import ImageGrab
import ctypes
import time
import pyaudio
import wave
import win32gui, win32ui, win32con, win32api

import warnings
warnings.simplefilter("ignore", DeprecationWarning)

hnd = win32gui.GetDesktopWindow()
width = 1920
height = 1080
windc = win32gui.GetWindowDC(hnd)
srcdc = win32ui.CreateDCFromHandle(windc)
memdc = srcdc.CreateCompatibleDC()
bmp = win32ui.CreateBitmap()
bmp.CreateCompatibleBitmap(srcdc, width, height)
memdc.SelectObject(bmp)

user32 = ctypes.windll.user32
capSize = (user32.GetSystemMetrics(0), user32.GetSystemMetrics(1))
print(capSize)

fourcc = cv2.VideoWriter_fourcc(*"DIVX")
writer = cv2.VideoWriter("test.mov", fourcc, 20, capSize)
count = 0
FirstFlag = True

WAVE_OUTPUT_FILENAME = "test.wav"
RECORD_SECONDS = 5

FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 44100
CHUNK = 2 ** 11
audio = pyaudio.PyAudio()
stream = audio.open(format=FORMAT,
                    channels=CHANNELS,
                    rate=RATE,
                    input=True,
                    input_device_index=0,
                    frames_per_buffer=CHUNK)

frames = []

sTime = time.time()
count = 0

arrScreenShot = []

print ("start")
for i in range(0, int(RATE / CHUNK * RECORD_SECONDS)):
    count+=1
    if count == 30 :
        current = time.time()
        diff = (current - sTime)
        print("fps:" +  str(float(count)/diff))
        sTime = time.time()
        count = 0

    # 画像キャプチャ
    # fps18
    # img_cv = np.asarray(ImageGrab.grab())
    # img = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)
    # writer.write(img)

    # fps29
    memdc.BitBlt((0, 0), (width, height), srcdc, (0, 0), win32con.SRCCOPY)
    img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)

    #動画書き出し
    writer.write(img)

    # # 音声キャプチャ
    # data = stream.read(CHUNK)
    # frames.append(data)

writer.release()
stream.stop_stream()
stream.close()
audio.terminate()

waveFile = wave.open(WAVE_OUTPUT_FILENAME, 'wb')
waveFile.setnchannels(CHANNELS)
waveFile.setsampwidth(audio.get_sample_size(FORMAT))
waveFile.setframerate(RATE)
waveFile.writeframes(b''.join(frames))
waveFile.close()
print ("finish")
```

音声キャプチャの処理をコメントアウトしています。
画面キャプチャと音声キャプチャを同時にやると遅くなるので、
並列処理で書き直す予定です。（一番下のやつは書き直してます）

とりあえず今は一旦画面キャプチャを完結させましょう。

```
fps:20.089757121871102
```

上記ソースを実行して速度をはかってみると・・・遅いですね。
原因は動画を書きだす処理の部分にあります。

```
    writer.write(img)
```

ループ内で行われるこの処理の存在がfpsを10くらい下げています。
この関数はopencvの関数、cv2.VideoWriterというクラスの関数です。
もしも動画を書きだすライブラリで他に早いものがあるなら、そちらを使いたいですね。


#遅延・早送り対策の検討

この関数を使った時の問題は、私が把握している仕様の範囲では以下の通りです。

```
１．書き込むとき遅い。
２．固定のフレームレートでしか動画を保存できない。
```

opencvとは別の動画書き出しライブラリがあるならそっちも検証したくなる問題点です。


２のほうについて補足すると、
前回画面をキャプチャしたとき、fpsは14～19fpsと
かなりばらつきがある結果になっていました。
そのせいで、出力された動画時間がズレていましたね。

多少のfpsのズレは無視して固定フレームで書きこんでも良いのですが、
最終的に動画と音声を合わせることを考えると、
できることなら正確に保存したくなります。

iosの標準apiでは、動画を出力する時は
フレーム画像と一緒にタイムスタンプを渡して
動画を保存していたのを見た気がします。
そんな感じで可変フレームレート(VFR)で動画を保存できれば一番楽そうです。
加えて、書き出し速度も速いと尚良い。

そんなライブラリが他にないか調べてみましょう。

・・・・
・・・
・・
・

・・・調べたけどなかなか見当たりませんでした。
妥協案でいくしかなさそうです。

```
１．書き込むとき遅い。
```

できればキャプチャした後にすぐ書き込むようにしたかったですが諦めましょう。
配列にキャプチャ画像を保持して、撮影後に処理するようにしてみます。

保持したデータを何かしらの形で出力しないと、
長時間録画したらあっという間にメモリがパンクしそうです。
しかし今は機能を満たすことを優先して目をつぶりましょう。


```
２．固定のフレームレートでしか動画を保存できない。
```

2の対策の前に現状の問題を整理します。
ためしに、各画像がキャプチャされる時の一枚当たりの処理時間をグラフにしてみます。


```
…（略）…

arrScreenShot = []
imgList = []
graph = []
print ("start")
for i in range(0, int(RATE / CHUNK * RECORD_SECONDS)):
    count+=1
    current = time.time()
    diff = (current - sTime)
    graph.append(diff)
    # print( str(i) + "フレーム目：" + str(diff))
    sTime = time.time()
    count = 0

    # 画像キャプチャ
    memdc.BitBlt((0, 0), (width, height), srcdc, (0, 0), win32con.SRCCOPY)
    img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
    imgList.append(img)


import statistics
import matplotlib.pyplot as plt
median = statistics.median(graph)
print("中央値:" + str(median))
x = list(range(len(graph)))
x3 = [median] * len(graph)
plt.plot(x, graph,  color = "red")
plt.plot(x, x3,  color = "blue")
plt.show()

…（略）…
```

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/4b321a94-cd8b-ca93-70c1-abf014a56284.png)

横軸はキャプチャしたフレーム画像の枚数。（合計107枚）
縦軸は1フレームあたりの処理時間(s)。
例えば30fpsであれば、1枚当たりの処理時間は0.03sになります。

赤い線は測定した処理時間のプロット線、
青い線は赤い線の中央値。値は0.03341841697692871。fpsで言えば29.9です。


グラフをみると、立ち上がりの部分の処理時間が
極めて遅くなっていることがわかりますね。
これが早送り・遅送りの最大の原因のようです。

対策として以下のものを考えました。

```
１．可変フレームレートで動画を書き込む
２．一定の時間間隔でスクリーンショットが撮影されるように修正し、
　　固定フレームで動画を書き込む
３．指定した時間間隔を超える・足りないフレーム画像は無視して前回のフレームを使用し、
　　固定フレームで動画を書き込む。
４．処理が遅すぎる最初の部分は録画しない。20フレーム以降を対象とする。
```

１はVFRで書き込めるライブラリを探しましたが見つからないので諦めました。
４が一番楽そうですが、これだと動画と音声で再生時間にずれが生れます。
どれくらいズレているかも図れるでしょうが、
最終的に音と動画をマージさせることを考えるとちょっと気持ち悪いです。

なんとなーく、２が一番理にかなっていそうです。
２の方針で行ってみましょう。


#遅延・早送り対策の検討：一定の時間間隔で画像をキャプチャする

現状、forでループするたびに画面のキャプチャ処理が実行される仕組みです。
一定時間の間隔でキャプチャすることは想定されていません。

https://qiita.com/montblanc18/items/05715730d99d450fd0d3
ということで、こちらのサイトを参考にして、
一定時間間隔で出力してみます。
とりあえず何も考えずにそのまま実行してみます。

```
～（略）～

import time
import threading

def worker():
    print(time.time())
    memdc.BitBlt((0, 0), (width, height), srcdc, (0, 0), win32con.SRCCOPY)
    img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
    imgList.append(img)

def scheduler(interval, f, wait = True):
    base_time = time.time()
    next_time = 0
    while True:
        t = threading.Thread(target = f)
        t.start()
        if wait:
            t.join()
        next_time = ((base_time - time.time()) % interval) or interval
        time.sleep(next_time)

scheduler(0.035, worker, False)
exit()

～（略）～

```

結果、多くのフレームは無事動画として出力されましたが、ちょくちょく失敗していました。

原因は一つのとあるオブジェクトを複数のスレッドで参照したことが原因と思われます。
これまで、画面キャプチャをつかさどるmemdcという１つのインスタンスを
ひたすら使い回してきました。

スレッド処理にしたことで、１つのインスタンスを参照しあって
しっちゃかめっちゃかになっています。書き換えましょう。


```
～（略）～
frames = []

sTime = time.time()
count = 0

arrScreenShot = []
imgList = []
graph = []
print ("start")

import time
import threading

def worker(imgList):
    print(time.time())
    imgList.append(win32con.SRCCOPY)

def scheduler(interval,MAX_SECOND, f, wait = False):
    base_time = time.time()
    next_time = 0
    while (time.time()-base_time) < MAX_SECOND:
        t = threading.Thread(target = f,args=(imgList,))
        t.start()
        if wait:
            t.join()
        next_time = ((base_time - time.time()) % interval) or interval
        time.sleep(next_time)

scheduler(1/fps, 40, worker, False)

for tmpSRCCOPY in imgList:
    memdc.BitBlt((0, 0), (width, height), srcdc, (0, 0), tmpSRCCOPY)
    img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
    img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
    writer.write(img)

～（略）～
```

うわクソソーs・・・それは置いておいて、
前回は40秒の動画を出力しようとしたら、28秒になって帰ってきました。
今回はどうなるでしょうか。


![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581710/918147d4-1ce4-b561-98c8-e629c1f50e09.png)

39秒。あ、うまくいってますね。
1秒たりないのは小数点の問題なので問題なし。成功です。
画面キャプチャは一旦こんなもんで良しとして、
後半の方でより細かい精度の検証をしましょう。


さて、ここまでの実装で、
気になる点・積み残した問題点としては以下の通りです。

```
●配列に画像データをひたすら入れてるだけなので、
　長時間録画すると落ちそう。
●リアルタイムに録画するために高速なwin32を導入したが。
　結局リアルタイムで録画する当社の想定はなくなった。
　実はImageGrab.grab()を使うのと大差ない仕組みになった。
```



#まとめ
ソース整理します。
下記ライブラリを追加してから実行します。

```
pip install moviepy
```

このライブラリは動画と音声をマージさせるために使います。

そして、下記が録画と録音を同時に行うソースです。
遅延対策に、音声と画面のキャプチャを別のスレッドで実行するようにした……つもりです。
（ちょっとあやしい）


```python:capture.py
import cv2
import numpy as np
import pyaudio
import wave
import win32gui, win32ui, win32con, win32api
import time
import threading

#win32のapiでnumpyに変換しようとするときのエラー対策
import warnings
warnings.simplefilter("ignore", DeprecationWarning)

class VideoCap:
    FrameList=[]

    def __init__(self,width,height,fps,FileName):
        capSize = (width, height)

        fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
        self.writer = cv2.VideoWriter(FileName, fourcc, fps, capSize)

        hnd = win32gui.GetDesktopWindow()
        windc = win32gui.GetWindowDC(hnd)
        self.srcdc = win32ui.CreateDCFromHandle(windc)


    def RecordStart(self,fps,rec_time):
        def StoreFrameCap(FrameList):
            # print(time.time())
            FrameList.append(win32con.SRCCOPY)

        def scheduler(interval, MAX_SECOND, f, wait=False):
            base_time = time.time()
            next_time = 0
            while (time.time() - base_time) < MAX_SECOND:
                t = threading.Thread(target=f, args=(self.FrameList,))
                t.start()
                if wait:
                    t.join()
                next_time = ((base_time - time.time()) % interval) or interval
                time.sleep(next_time)

        scheduler(1 / fps,rec_time, StoreFrameCap, False)

    def RecordFinish(self):
        for tmpSRCCOPY in self.FrameList:
            memdc = self.srcdc.CreateCompatibleDC()
            bmp = win32ui.CreateBitmap()
            bmp.CreateCompatibleBitmap(self.srcdc, width, height)
            memdc.SelectObject(bmp)
            memdc.BitBlt((0, 0), (width, height), self.srcdc, (0, 0), tmpSRCCOPY)

            img = np.fromstring(bmp.GetBitmapBits(True), np.uint8).reshape(height, width, 4)
            img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB)
            self.writer.write(img)

        memdc.DeleteDC()
        win32gui.DeleteObject(bmp.GetHandle())

        self.writer.release()

class AudioCap:

    class default:
        FORMAT = pyaudio.paInt16
        CHANNELS = 1
        RATE = 44100
        CHUNK = 2 ** 11

    frames=[]
    audio = pyaudio.PyAudio()

    def __init__(self,FORMAT=default.FORMAT,CHANNELS=default.CHANNELS,RATE=default.RATE,CHUNK=default.CHUNK):
        self.FORMAT = FORMAT
        self.CHANNELS = CHANNELS
        self.RATE = RATE
        self.CHUNK = CHUNK

    def RecordStart(self,rec_time):
        self.stream = self.audio.open(format=self.FORMAT,
                        channels=self.CHANNELS,
                        rate=self.RATE,
                        input=True,
                        input_device_index=0,
                        frames_per_buffer=self.CHUNK)
        for i in range(0, int(self.RATE / self.CHUNK * rec_time)):
            data = self.stream.read(self.CHUNK)
            self.frames.append(data)

    def RecordFinish(self):
        self.stream.stop_stream()
        self.stream.close()
        self.audio.terminate()

    def writeWAV(self,FileName):
        waveFile = wave.open(FileName, 'wb')
        waveFile.setnchannels(self.CHANNELS)
        waveFile.setsampwidth(self.audio.get_sample_size(self.FORMAT))
        waveFile.setframerate(self.RATE)
        waveFile.writeframes(b''.join(self.frames))
        waveFile.close()

# 基本設定
width = 1920        # 解像度ヨコ
height = 1080       # 解像度タテ
fps = 30              #　FPS
RECORD_SECONDS = 60  #　再生時間
VIDEO_OUTPUT_FILENAME = "test.mp4"       #音声ファイル
AUDIO_OUTPUT_FILENAME = "test.wav"       #動画ファイル
FINAL_VIDEO = "final_video.mp4"       #動画＋音声ファイル


#インスタンス
CapAuidio = AudioCap()
CapVideo = VideoCap(width,height,fps,VIDEO_OUTPUT_FILENAME)

#音声処理のスレッド用
def threadFuncAudio(obj):
    obj.RecordStart(RECORD_SECONDS)
    obj.RecordFinish()
    obj.writeWAV(AUDIO_OUTPUT_FILENAME)

thrAudio = threading.Thread(target=threadFuncAudio(CapAuidio,))

#同時キャプチャ開始
thrAudio.start()
CapVideo.RecordStart(fps,RECORD_SECONDS)
CapVideo.RecordFinish()


#検証：再生時間の差どれくらい？
from pydub import AudioSegment
sound = AudioSegment.from_file(AUDIO_OUTPUT_FILENAME, "wav")
time = sound.duration_seconds # 再生時間(秒)
print('音声：再生時間：', time)

cap = cv2.VideoCapture(VIDEO_OUTPUT_FILENAME)
print('動画：再生時間：',cap.get(cv2.CAP_PROP_FRAME_COUNT) / cap.get(cv2.CAP_PROP_FPS))

#検証：動画・音声のマージ
from moviepy.editor import VideoFileClip
from moviepy.editor import AudioFileClip

my_clip = VideoFileClip(VIDEO_OUTPUT_FILENAME)
audio_background = AudioFileClip(AUDIO_OUTPUT_FILENAME)
final_clip = my_clip.set_audio(audio_background)
final_clip.write_videofile(FINAL_VIDEO, fps=fps)
```

結果は以下の通りです。

```
音声：再生時間： 4.96907029478458
動画：再生時間： 4.933333333333334
Moviepy - Building video final_video.mp4.
MoviePy - Writing audio in final_videoTEMP_MPY_wvf_snd.mp3
MoviePy - Done.
Moviepy - Writing video final_video.mp4

Moviepy - Done !
Moviepy - video ready final_video.mp4

Process finished with exit code 0
```


5秒の動画で0.03秒のズレがありますね。
録画時間が長いとズレが大きくなったりするんでしょうか。

```
音声：再生時間： 59.953922902494334
動画：再生時間： 59.06666666666667
```

60秒で約1秒の遅延・・・。固定フレームで動画を書きだすと
どうあっても遅延は発生しそうです。


次回以降は、
●動画と音声でどれくらいのズレがあるか
●長時間録画したらどんな挙動になるか
を検討します。

次回に続く