【OpenCV + MoviePy】画像から動画を自動生成してみた！

はじめまして！[Nancy](https://twitter.com/s_2010_119)と申します🌷

こちらは[Teamうどん](https://twitter.com/team_udn)さんが主催されている，[初めてのアドベントカレンダー](https://qiita.com/advent-calendar/2021/first-try) 5日目の記事です🎄
わたしも初めてアドベントカレンダーに登録したので，ルール違反？などがあればご指摘をお願いいたします🙇‍♂️

# はじめに
[とあるハッカソン](https://jphacks.com/2021)にて，動画生成を行うプロダクトを開発したのですが，MoviePyに関わる日本語の記事がほとんどなくかなり苦戦したため，自分でまとめてみることにしました！💐
1週間で調べたことなので，誤りなどがあれば気軽にコメントをしていただけると嬉しいです🔰

# 使用環境
- Python 3.8.3
- OpenCV 4.5.1
- MoviePy 1.0.3
- NumPy 1.18.5

# 今回使用する画像
みなさんご存知のLennaさん(`Lenna.png`)をお借りします．
<div align="center">
<img width="512px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1092916/68eb3765-8b08-7386-6916-d28a5d800c0e.png" title="Lenna.png">
</div>

# 今回生成する動画
今回は0.5秒ごとにLennaさんが拡大と縮小を繰り返すgifファイルを生成します．

:::note info
Qiitaにmp4ファイルをアップロードすることができなかったため今回はgifファイルですが，mp4ファイルも書き出すことができます．
:::

<div align="center">
<img width="250px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1092916/4439a993-cbf4-430f-bf18-ca3a9fd42fa5.gif" title="Lenna.gif">
</div>

# ソースコード
```python
import cv2
import moviepy.editor as mpy
import numpy as np

# 変数
MOVIE_LENGTH = 10
FPS = 30
MOVIE_FRAMES = MOVIE_LENGTH * FPS
SECONDS_PER_FRAME = 1 / FPS
BASE_SIZE = 250
BASE_COLOR = [255, 255, 255]
clips = []

# 画像の読み込み
lenna_origin = cv2.imread('Lenna.png')
lenna_origin = cv2.cvtColor(lenna_origin, cv2.COLOR_BGRA2RGBA)

# 背景画像の準備
base_img = np.full((BASE_SIZE, BASE_SIZE, 3), BASE_COLOR)
base_clip = mpy.ImageClip(base_img).set_duration(MOVIE_LENGTH)

# 画像を円形に切り出し，クリップに変換する
for i in range(MOVIE_FRAMES):
    # 深いコピー
    lenna = lenna_origin.copy()

    # 画像の拡大縮小
    if i % FPS < FPS // 2:
        new_size = 200 - 50 * (i % 15) // 15
    else:
        new_size = 150 + 50 * (i % 15) // 15
    lenna = cv2.resize(lenna, dsize=(new_size, new_size))

    # マスク処理
    mask = np.zeros((new_size, new_size))
    cv2.circle(mask, center=(new_size//2, new_size//2), radius=new_size//2, color=255, thickness=-1)
    lenna[mask==0] = [0, 0, 0, 0]

    # 画像をクリップ化
    clip = mpy.ImageClip(lenna).set_duration(SECONDS_PER_FRAME)
    clips.append(clip)

# 動画を作成する処理
lenna_clip = mpy.concatenate_videoclips(clips)
clip.close()

# クリップの合成
final_clip = mpy.CompositeVideoClip([base_clip, lenna_clip.set_position(('center'))])
final_clip.write_gif(filename = 'Lenna.gif', fps=FPS)
final_clip.close()
```

急に長々としたソースコードで訳が分からないと思うので，細かく見ていきたいと思います．

## 変数
```python
MOVIE_LENGTH = 10
FPS = 30
MOVIE_FRAMES = MOVIE_LENGTH * FPS
SECONDS_PER_FRAME = 1 / FPS
BASE_SIZE = 250
BASE_COLOR = [255, 255, 255]
clips = []
```
今回はこのような変数を用います．

- `MOVIE_LENGTH` : 作成する動画の長さ(秒)
- `FPS` : 作成する動画のFPS(`30`または`60`)
- `MOVIE_FRAMES` : 作成する動画のフレーム数
- `SECONDS_PER_FRAME` : フレーム1枚の長さ(秒)
- `BASE_SIZE` : 背景画像のサイズ(pixel)
- `BASE_COLOR` : 背景画像のRGB値(今回は白)
- `clips` : 作成したクリップを格納するための空配列

## 画像の読み込み
```python
lenna_origin = cv2.imread('Lenna.png')
lenna_origin = cv2.cvtColor(lenna_origin, cv2.COLOR_BGRA2RGBA)
```
Lennaさんの画像を読み込みます．後から透過処理を行うため，`BGR`画像から`RGBA`画像へ変更します．

## 背景画像の準備
```python
base_img = np.full((BASE_SIZE, BASE_SIZE, 3), BASE_COLOR)
base_clip = mpy.ImageClip(base_img).set_duration(MOVIE_LENGTH)
```
全ての要素が`BASE_COLOR`で，大きさが`(BASE_SIZE, BASE_SIZE, 3)`の`Numpy`型配列`base_img`を準備します．そして，`base_img`を長さ`MOVIE_LENGTH`のクリップ(`base_clip`)に変換します．

## 画像を円形に切り出し，クリップに変換する
```python
for i in range(MOVIE_FRAMES):
    # 深いコピー
    lenna = lenna_origin.copy()

    # 画像の拡大縮小
    if i % FPS < FPS // 2:
        new_size = 200 - 50 * (i % 15) // 15
    else:
        new_size = 150 + 50 * (i % 15) // 15
    lenna = cv2.resize(lenna, dsize=(new_size, new_size))

    # マスク処理
    mask = np.zeros((new_size, new_size))
    cv2.circle(mask, center=(new_size//2, new_size//2), radius=new_size//2, color=255, thickness=-1)
    lenna[mask==0] = [0, 0, 0, 0]

    # 画像をクリップ化
    clip = mpy.ImageClip(lenna).set_duration(SECONDS_PER_FRAME)
    clips.append(clip)
```
ここでは1フレームごとの画像を生成し，画像をクリップに変換します．

### 深いコピー
```python
    lenna = lenna_origin.copy()
```
ここで，あらかじめ`cv2.imread()`しておいたLennaさんを，深いコピーします．

:::note warn
注意
今回は画像の切り抜きかたが全てのフレームで同じため，浅いコピーでもいいのですが，フレームによって切り抜き方を変えたい(画像サイズは同じで，切り抜く大きさを変えたいなどの)場合には，浅いコピー(lenna = lenna_origin)をしてしまうと，挙動が変わってしまいます．
:::

### 画像の拡大縮小
```python
    if i % FPS < FPS // 2:
        new_size = 200 - 50 * (i % 15) // 15
    else:
        new_size = 150 + 50 * (i % 15) // 15
```
今回は30FPSのため，

- 現在のフレーム数`i`を30(`FPS`)で割ったあまり(`i % FPS`)が15(`FPS // 2`)より小さい場合には，200(pixel)から150(pixel)まで縮小
- 現在のフレーム数`i`を30(`FPS`)で割ったあまり(`i % FPS`)が15(`FPS // 2`)以上の場合には，150(pixel)から200(pixel)まで拡大

するように`new_size`を決定しました．

### マスク処理
```python
    mask = np.zeros((new_size, new_size))
    cv2.circle(mask, center=(new_size//2, new_size//2), radius=new_size//2, color=255, thickness=-1)
    lenna[mask==0] = [0, 0, 0, 0]
```

全ての要素が`0`(黒)で，大きさが[画像の拡大縮小](#画像の拡大縮小)で決定した`(new_size, new_size)`の`Numpy`型配列`mask`を準備します．
円を描画する関数`cv2.circle()`を利用して，マスクの残したい部分である，

- `center = (new_size//2, new_size)//2` : 中心の座標
- `radius = new_size//2` : 半径
- `color = 255` : 色(白)
- `thickness = -1` : 線の太さ(塗りつぶし)

を描画します．
`lenna`のうち，`mask`の値が`0`の画素は透過(アルファチャネルを`0`に)することで，マスク処理ができました．

### 画像のクリップ化
```python
    clip = mpy.ImageClip(lenna).set_duration(SECONDS_PER_FRAME)
    clips.append(clip)
```
[画像の読み込み](#画像の読み込み)と同様に，`lenna`を長さ`SECONDS_PER_FRAME`のクリップ(`clip`)に変換し，`clips`に格納します．

## クリップをつなぎ合わせる
```python
lenna_clip = mpy.concatenate_videoclips(clips)
clip.close()
```
[画像のクリップ化](#画像のクリップ化)で生成した`clips`のクリップを`moviepy.editor.concatenate_videoclips()`を用いてつなぎ合わせます．
メモリリークを防ぐために，使い終わったら`close()`をしましょう．

## クリップの合成
```python
final_clip = mpy.CompositeVideoClip([base_clip, lenna_clip.set_position(('center'))])
final_clip.write_gif(filename = 'Lenna.gif', fps=FPS)
final_clip.close()
```
最後に，作成したクリップを`moviepy.editor.CompositeVideoClip()`を用いて合成します．`set_position()`を用いることで，1つ目のクリップから見た相対座標に重ねることができます．
`write_gif()`を用いてgifファイルを書き出します．

:::note info
write_gif()の代わりにwrite_videofile() を用いれば，mp4ファイルで書き出せます．
:::

ここでも忘れずに，`close()`をしてください．

# まとめ

- OpenCVとMoviePyを組み合わせることで，画像をクリップ化し繋ぎ合わせた動画を生成することができる

# 追記
この技術を用いたプロダクトが「バンダイナムコ研究所賞」を受賞することができました！🧡
初めてハッカソンで受賞できたので，とても嬉しいです💐

その他の企業賞・特別賞作品は[こちら](https://jphacks.com/2021/result/company/)からご確認いただけます！

<div align="center">
<img width="512px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1092916/7cd4c3f8-f9ce-a2f8-4ea0-7a9befdb353c.jpeg">
</div>