動画作成を並列処理して高速化してみた

# 遅い！！！とさすがに思った
[字幕から文字抽出してみた(OpenCV:tesseract-ocr編)](https://qiita.com/satsukiya/items/112c6c08a618b2376408)で動画で画像処理をしてみた結果810秒（約13.5分）とかかってしまいました。ここでは、リファクタリングをして動画作成する速度を改善してみました。


#方法1 PIL <-> OpenCV
同一の画像データをPILからOpenCVに移して処理しようとした時にファイルに一時的に保存してからリードをしていたが、よくよく調べてみるとnumpyの型を変換するだけでできるみたいです。これで
810秒 (約13.5分) -> 450秒 (約7.5分)
まで削減することができました。

修正前

```Python
def createTextImage(src, sentence, px, py, color=(8,8,8), fsize=28):

    #画像を保存
    tmp_path = "src_temp.png"
    cv2.imwrite(tmp_path, src)

    #PILオブジェクトに格納
    img = Image.open(tmp_path)
    draw = ImageDraw.Draw(img)

    #PILで文字を画像に書き込み
    font = ImageFont.truetype("./IPAfont00303/ipag.ttf", fsize)
    draw.text((px, py), sentence, fill=color, font=font)
    img.save(tmp_path)

    #openCVに格納
    return cv2.imread(tmp_path)
```

修正後

```Python
opencv(BGR) -> PIL(RGB)

cvimg = cv2.imread("sample.png")
rgbImg = cv2.cvtColor(cvimg, cv2.COLOR_BGR2RGB)
pilImg = Image.fromarray(rgbImg)

------------------

PIL(RGB) -> opencv(BGR)
cvImg = np.array(pilImg, dtype=np.uint8)
dst = cv2.cvtColor(cvImg, cv2.COLOR_RGB2BGR)
```

# 方法2 並列処理
一つのフレームごとに動画の読み込み、処理、書き込みを逐次処理していたわけですが、処理のみを並列化するために下記のフローでスクリプトを書いてみました。

- 配列にフレームを一時的に格納
- 各フレームの字幕抽出を並列に処理して集約する
- id別にソートをかける
- 動画に保存

並列処理は `joblib ` を使っています。 callback元では内包処理を駆使してほぼ1行で書けます。 `n_jobs=16`はプロセス数です。

```Python

from joblib import Parallel, delayed

def main_image_process(src, tool):
    
    #ここでいくつかの画像処理をしてみる
        #前処理
        gray_frame = pre_process(src.content)
        #文字抽出
        #字幕作成

...
Parallel(n_jobs=16)( [delayed(main_image_process)(f, tool) for f in frames] )
...

```


# 開発

```Python3
import sys

import cv2
import io
import os
import numpy as np

import pyocr
import pyocr.builders

from PIL import Image, ImageDraw, ImageFont

from collections import namedtuple
from joblib import Parallel, delayed

import time

MovieFrame = namedtuple("MovieFrame", ["id", "content", "timestamp"])

telop_height = 50
cap_width = 1
cap_height = 1


def pre_process(src):
	kernel = np.ones((3,3),np.uint8)
	gray = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)
        
    #二値化
	o_ret, o_dst = cv2.threshold(gray, 0, 255, cv2.THRESH_OTSU)
    #オープニング 縮小->拡大
	dst = cv2.morphologyEx(o_dst, cv2.MORPH_OPEN, kernel)
    #反転
	dst = cv2.bitwise_not(dst)
    # channel 1 -> 3 に変換
	dst = cv2.cvtColor(dst, cv2.COLOR_GRAY2BGR)
	return dst

#文字抽出
def extractTelopText(src, tool):

	rgbImg = cv2.cvtColor(src, cv2.COLOR_BGR2RGB)
	dst = tool.image_to_string(
		Image.fromarray(rgbImg),
		lang='jpn',
		builder=pyocr.builders.WordBoxBuilder(tesseract_layout=6)
	)

	sentence = []
	for item in dst:
		sentence.append(item.content)

	return "".join(sentence)

#空の字幕作成
def createFooterTelop(src):

	telop = np.zeros((telop_height, cap_width, 3), np.uint8)
	telop[:] = tuple((128,128,128))

	images = [src, telop]
	dst = np.concatenate(images, axis=0)
	return dst

#並列処理
def main_image_process(src, tool):

	#文字認識しやすいように加工
	gray_frame = pre_process(src.content)

	#テロップが出そうなところだけトリミング
	roi = gray_frame[435:600, :]

	#テキストを抽出
	text = extractTelopText(roi, tool)
        
	#字幕作成
	dst = createFooterTelop(src.content)

	#画像に文字を追加
	dst = addJapaneseTelop(dst, text, 20, cap_height + telop_height - 30)
	dst = addASCIITelop(dst, str(src.timestamp) + "[sec]", cap_width - 250, cap_height + telop_height - 10, color=(0,255,0))

        #nametubleに格納
	return MovieFrame(src.id, dst, src.timestamp)

#文字追加(英数字のみ)
def addASCIITelop(src, sentence, px, py, color=(8,8,8), fsize=28):

	cv2.putText(src, sentence, 
						(px, py), 
						cv2.FONT_HERSHEY_SIMPLEX, 
						1, 
						color, 
						2, 
						cv2.LINE_AA)	
	return src

#文字追加(日本語)
def addJapaneseTelop(src, sentence, px, py, color=(8,8,8), fsize=28):

	rgbImg = cv2.cvtColor(src, cv2.COLOR_BGR2RGB)

	#openCV -> PIL
	canvas = Image.fromarray(rgbImg).copy()

	draw = ImageDraw.Draw(canvas)
	font = ImageFont.truetype("./IPAfont00303/ipag.ttf", fsize)

	#文字追加 
	draw.text((px, py), sentence, fill=color, font=font)

	#PIL -> openCV	
	dst = cv2.cvtColor(np.array(canvas, dtype=np.uint8), cv2.COLOR_RGB2BGR)

	return dst

if __name__ == '__main__':

	tools = pyocr.get_available_tools()
	if len(tools) == 0:
		print("No OCR tool found")
		sys.exit(1)

	tool = tools[0]

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

	cap_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
	cap_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
	fps = cap.get(cv2.CAP_PROP_FPS)

	telop_height = 50

	fourcc = cv2.VideoWriter_fourcc('m','p','4','v')
	writer = cv2.VideoWriter('extract_telop_async.mp4',fourcc, fps, (cap_width, cap_height + telop_height))

	frames = []

	start = time.time()
	idx = 0

	#動画読み込み
	try :
		while True:
			if not cap.isOpened():
				break

			if cv2.waitKey(1) & 0xFF == ord('q'):
				break

			ret, frame = cap.read()

			if frame is None:
				break

			frames.append(MovieFrame(idx,frame, round(idx/fps, 4)) )
			idx += 1

	except cv2.error as e:
		print(e)

	cap.release()
	print("read movie file")
	
	#並列処理(呼び出し元)
	r = Parallel(n_jobs=16)( [delayed(main_image_process)(f, tool) for f in frames] )

	#sort
	sorted_out = sorted(r, key=lambda x: x.id)

	#動画書き込み
	try :
		for item in sorted_out:
			writer.write(item.content)

	except cv2.error as e:
		print(e)


	writer.release()
	
	print("write movie file")
	print("Done!!! {}[sec]".format(round(time.time() - start,4)))
```

## その他のポイント

### nametuples

- フレームの順番
- 画像データ
- タイムスタンプ

をひとまとめにして処理したかったので簡易的なオブジェクトを作成しようと思って`namedtuples`を使ってみました。


```python
MovieFrame = namedtuple("MovieFrame", ["id", "content", "timestamp"])
```

これでプロパティ(getter)と同様に `src.id`、`src.content`と設定ができるのでコード短縮ができていいです。

### ソート

並列処理後に集約されたフレームですが、少しだけ順番がずれている可能性があります。
そのため、表示する順番(id)にソートをかけています

```Python
sorted_out = sorted(r, key=lambda x: x.id)
```

# 結果
処理時間は。。。
267.7924秒(約４.5分)
に短縮しました。:tada:

最初の810秒に比べて大きな違いです。

|       | 処理時間 [sec] |処理時間 [min]  |
|:-----------------|------------------:|------------------:|
| 修正前             |              810 | 13.5|
| 方法１(PIL <-> OpenCV)           |    450 | 7.5|
| 方法１(PIL <-> OpenCV) + 方法2(並列処理) | 268 | 4.46| 

# おわりに
エラーメッセージが表示されにくいので、一度プロセス数をひとつにしてバグをつぶしてからでないと最初から並列処理をかけるのは難しいです。
日本語で文字追加する処理では `canvas = Image.fromarray(rgbImg).copy()` と画像データを複製しないと逐次処理は出来ても並列処理ではエラーになりました。
少し環境を準備しないと並列処理出来ないので、処理は速いのですがどうしても面倒に思ってしまうとです。:tired_face:

# 参考にしたリンク
- [pil python multiple Image independent draw](https://stackoverflow.com/questions/38174726/pil-python-multiple-image-independent-draw)
- [joblib.Parallel (公式 doc)](https://joblib.readthedocs.io/en/latest/generated/joblib.Parallel.html)
- [Fluent Python 第17章 futuresを使った並行処理 (Python 3.7版)] (https://qiita.com/daizutabi/items/abdd0a3ded0dfc3b232a)
- [Python documentation : namedtuple](https://docs.python.org/ja/3/library/collections.html#collections.namedtuple)
- [namedtupleで美しいpythonを書く！（翻訳）](https://qiita.com/Seny/items/add4d03876f505442136)
- [Pythonでリストの内容をソートする方法｜sort・sorted](https://www.sejuku.net/blog/51389)