【試行錯誤】「〇〇で歌ってみた」動画の自動生成 その４：静止画の生成

# 概要
前回作成した、替え歌情報に基づいて、動画用の静止画を生成する関数を作ります。
最終的に以下のようなファイルが得ることを目指します（上から替え歌歌詞を表す画像、替え歌歌詞、元歌詞を並べた画像です）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/2aa9d417-027f-03d9-16a0-8e31424fa274.png)

シリーズ一覧：
[【試行錯誤】「〇〇で歌ってみた」動画の自動生成 リンクまとめ](https://qiita.com/shimajiroxyz/items/5df23a0ca81a3a2d9568)

# 背景
最終成果物として、替え歌歌詞、元歌詞、替え歌歌詞に対応する画像をセットで表示した静止画を音楽に合わせて流した動画を想定しています。
そこで、まず静止画を作る関数を実装し、替え歌情報に基づいて実際に使う画像を作成します。

以下３つのステップを行います。

* 事前準備
* 静止画生成関数の作成
* 替え歌情報に基づく静止画生成

# 事前準備
「その３」で作った替え歌情報のデータフレームを用意し、読み込んでおきます。

```:output/harugakita/parodyinfo.csv
parodytext,start,duration,originaltext,picture_path
マルタ,2.4,1.1999999999999997,春が来た　春が来た　どこに来た,Flag_of_Malta.png
ニカラグア,3.6,2.399999999999998,春が来た　春が来た　どこに来た,Flag_of_Nicaragua.png
イラン,5.999999999999998,1.1999999999999993,春が来た　春が来た　どこに来た,Flag_of_Iran.png
コロンビア,7.1999999999999975,4.200000000000001,春が来た　春が来た　どこに来た,Flag_of_Colombia.png
ハンガリー,11.999999999999998,1.2000000000000028,山に来た　里に来た　野にも来た,Flag_of_Hungary.png
...
```

```Python
import pandas as pd
parodyinfo_df = pd.read_csv("output/harugakita/parodyinfo.csv")
```

また国旗画像を一つのフォルダにまとめて格納しておきます。今回は`./figs`下にあるものとします。

# 静止画生成関数の実装
## 方針
今回は以下のように、上から順に画像、替え歌歌詞、元歌詞を並べた画像をつくります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/5bd58bbe-c23f-1203-9196-d2faadd2dbd2.png)

以下のステップで作成します。

* 単色（黒）の背景画像を用意
* 上部2/3の高さ幅に国旗画像をリサイズして貼り付け
* その下1/6の高さ幅に、替え歌歌詞（単語）を貼り付け
* 下部1/6の高さ幅に、元歌詞（フレーズ）を貼り付け

## 関数全体
関数の最終形を先に載せておきます。

```Python
from PIL import Image,ImageFont,ImageDraw
import numpy as np
from PIL import Image
from matplotlib import pyplot as plt

%matplotlib inline
# Jupyter Notebookでインライン表示する

def generate_picture(picture_path, originaltext, parodytext, *, field_size = (1920, 1080)):
  # 定数の定義
  object_area_height_ratio = 2/3 # オブジェクトの描画領域の高さの割合
  parodytext_area_height_ratio = 1/6 # 替え歌歌詞の描画領域の高さの割合
  originaltext_area_height_ratio = 1 - object_area_height_ratio - parodytext_area_height_ratio # 元歌詞の描画領域の高さの割合
  object_width_limit, object_height_limit = 0.8, 0.8 # 描画領域に対するオブジェクトの最大サイズ
  parodytext_height_limit = 0.6 # 描画領域に対する替え歌歌詞の最大高さ
  originaltext_height_limit = 0.3 # 描画領域に対する元歌詞の最大高さ
  
  # 背景を定義
  image = Image.new("RGB", field_size)
  # オブジェクトをpaste
  # オブジェクトが背景の上側にバランスよく収まるようにリサイズ
  field_width, field_height = field_size
  max_object_width = int( field_width * object_width_limit )
  max_object_height = int( field_height * object_area_height_ratio * object_height_limit)
  object_image = Image.open(picture_path)
  object_width, object_height = object_image.size
  object_aspect_ratio = object_height / object_width
  object_resized_height = int( max_object_width * object_aspect_ratio )
  object_resized_width = int( max_object_height * ( 1 / object_aspect_ratio) )
  # 描画領域よりもオブジェクトのほうが横長の場合
  if object_resized_width > max_object_width:
    object_resized_width = max_object_width
  # 描画領域よりもオブジェクトのほうが縦長の場合
  else:
    object_resized_height = max_object_height
  object_image = object_image.resize((object_resized_width, object_resized_height))
  object_x = int( field_width / 2 - object_resized_width / 2)
  object_y = int( field_height * object_area_height_ratio / 2 - object_resized_height / 2 )
  image.paste(object_image, (object_x, object_y))

  # 替え歌歌詞の描画
  draw = ImageDraw.Draw(image)
  parodytext_fontsize = int( field_height * parodytext_area_height_ratio * parodytext_height_limit )
  #print(parodytext_fontsize)
  font = ImageFont.truetype('ヒラギノ丸ゴ ProN W4.ttc', parodytext_fontsize)
  parodytext_center_x = int( field_width / 2 )
  parodytext_center_y = int( field_height * ( object_area_height_ratio + parodytext_area_height_ratio / 2 ) )
  parodytext_color = "white"
  draw.text(( parodytext_center_x, parodytext_center_y ), parodytext, parodytext_color, font=font, anchor="mm")

  # オリジナル歌詞の描画
  draw = ImageDraw.Draw(image)
  originaltext_fontsize = int( field_height * originaltext_area_height_ratio * originaltext_height_limit )
  #print(parodytext_fontsize)
  font = ImageFont.truetype('ヒラギノ丸ゴ ProN W4.ttc', originaltext_fontsize)
  originaltext_center_x = int( field_width / 2 )
  originaltext_center_y = int( field_height * ( 1 - originaltext_area_height_ratio / 2 ) )
  originaltext_color = "white"
  draw.text(( originaltext_center_x, originaltext_center_y ), originaltext, originaltext_color, font=font, anchor="mm")

  image_array = np.asarray(image)
  # notebookで出力を確認したいとき
  #plt.imshow(image_array) 
  #plt.show()
  # pltなしでみたい場合
  #image.show()
  # notebookであれば、戻り値だけをセルの最終行にかけばいい感じに出力してくれる

  return image
```

適当な単語で試してみます。

```Python
generate_picture("figs/Flag_of_Albania.png", "春が来た　春が来た　どこに来た", "あ")
```

notebookであれば以下のような出力が得られると思います。いい感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/2aa9d417-027f-03d9-16a0-8e31424fa274.png)

## 解説
### 黒背景の作成
以下の記述で指定されたサイズによる黒画像を作成しています。ライブラリはPillowを使っています。

```Python
  image = Image.new("RGB", field_size)
```

黒以外の背景にしたい場合は、第３引数にRGB値をいれればよいです。

```Python
  image = Image.new("RGB", field_size, (255, 0, 0) ) #赤
```

### 国旗画像の貼り付け
関数冒頭のobject_area_hegith_ratioで、上部からどの高さまでを画像領域とするかを定義しています。
また、余白を持って画像を貼り付けられるように、領域内でどの程度まで画像を拡大するかの割当をxxx_width/height_limitで幅、高さについて定義しています。

```Python
  # 定数の定義
  object_area_height_ratio = 2/3 # オブジェクトの描画領域の高さの割合
  object_width_limit, object_height_limit = 0.8, 0.8 # 描画領域に対するオブジェクトの最大サイズ
```

以下で領域内にちょうどいい感じに収まるようにリサイズしています。
画像が領域よりも横長の場合は、縦いっぱい、逆の場合は横いっぱいに拡大するようにしています。またPillowでは画像の左上座標を入力とするのですが、センタリングをしたかったので、センタリングできるような座標を計算しています。
画像のリサイズ、センタリングについては、以前、[パワポで似たようなこと](https://qiita.com/shimajiroxyz/items/bd4ae459d6c5ed46374d)を実装したので参考にしています。

```Python
  # オブジェクトをpaste
  # オブジェクトが背景の上側にバランスよく収まるようにリサイズ
  field_width, field_height = field_size
  max_object_width = int( field_width * object_width_limit )
  max_object_height = int( field_height * object_area_height_ratio * object_height_limit)
  object_image = Image.open(picture_path)
  object_width, object_height = object_image.size
  object_aspect_ratio = object_height / object_width
  object_resized_height = int( max_object_width * object_aspect_ratio )
  object_resized_width = int( max_object_height * ( 1 / object_aspect_ratio) )
  # 描画領域よりもオブジェクトのほうが横長の場合
  if object_resized_width > max_object_width:
    object_resized_width = max_object_width
  # 描画領域よりもオブジェクトのほうが縦長の場合
  else:
    object_resized_height = max_object_height
  object_image = object_image.resize((object_resized_width, object_resized_height))
  # センタリングした場合の左上座標の計算
  object_x = int( field_width / 2 - object_resized_width / 2)
  object_y = int( field_height * object_area_height_ratio / 2 - object_resized_height / 2 )
  # 画像の貼り付け
  image.paste(object_image, (object_x, object_y))
```

### 替え歌歌詞の貼り付け
替え歌歌詞も同様にまず、貼り付ける縦幅を定義します。以下だと、画像領域の下にフィールドサイズ高さの1/6の大きさを替え歌歌詞領域とするようにしています。また余白を作るための文字の最大サイズ割合も定義しています。横ははみ出さないような文字数にする想定なのでwidthは定義せず、heightだけを定義しています。

```Python
  # 定数の定義
  parodytext_area_height_ratio = 1/6 # 替え歌歌詞の描画領域の高さの割合
  parodytext_height_limit = 0.6 # 描画領域に対する替え歌歌詞の最大高さ
```

文字を貼り付けるには`ImageDraw`クラスを使います。
フォントは各自のOSに合わせて適切なパスを設定してください。
なお文字サイズを任意の値とするには`ImageFont`クラスが必要です。今回はフォントサイズは、領域の縦幅をはみ出さない程度に自動計算しています。縦幅しかみていないので、あまりに替え歌歌詞が長い場合は横にはみ出る可能性があります。その場合はparodytext_height_limitを小さくするか、長い文字列を使わないようにするなどしてください。
表示位置は、文字の場合は`draw`関数の`anchor`引数でセンタリング指定が可能ですので、その機能を使っています。


```Python  
  # 替え歌歌詞の描画
  draw = ImageDraw.Draw(image)
  parodytext_fontsize = int( field_height * parodytext_area_height_ratio * parodytext_height_limit )
  #print(parodytext_fontsize)
  font = ImageFont.truetype('ヒラギノ丸ゴ ProN W4.ttc', parodytext_fontsize)
  parodytext_center_x = int( field_width / 2 )
  parodytext_center_y = int( field_height * ( object_area_height_ratio + parodytext_area_height_ratio / 2 ) )
  parodytext_color = "white"
  # fontを使う場合、anchor引数にて表示位置指定が可能。横も縦もセンタリングなら"mm"
  draw.text(( parodytext_center_x, parodytext_center_y ), parodytext, parodytext_color, font=font, anchor="mm")
```

### 元歌詞の貼り付け
元歌詞の貼り付けは、替え歌歌詞の貼り付けとほぼ同様です。
領域の縦幅は、過去に定義したものから計算しています。
またheight_limitは替え歌歌詞よりも小さくしています。これは文字数が大きくなる想定のため、またなんとなく見栄えを考えたためです。

```Python
  # 定数の定義
  originaltext_area_height_ratio = 1 - object_area_height_ratio - parodytext_area_height_ratio # 元歌詞の描画領域の高さの割合
  originaltext_height_limit = 0.3 # 描画領域に対する元歌詞の最大高さ
```

```Python 
  # オリジナル歌詞の描画
  draw = ImageDraw.Draw(image)
  originaltext_fontsize = int( field_height * originaltext_area_height_ratio * originaltext_height_limit )
  #print(parodytext_fontsize)
  font = ImageFont.truetype('ヒラギノ丸ゴ ProN W4.ttc', originaltext_fontsize)
  originaltext_center_x = int( field_width / 2 )
  originaltext_center_y = int( field_height * ( 1 - originaltext_area_height_ratio / 2 ) )
  originaltext_color = "white"
  draw.text(( originaltext_center_x, originaltext_center_y ), originaltext, originaltext_color, font=font, anchor="mm")
```

# 替え歌情報にも基づく静止画生成
最後に、`parodyinfo_df`に基づいて動画に使う画像を生成するコードは以下のとおりです。
`parodyinfo_df`にはファイル名しか書いていないので、親ディレクトリ情報は別途定義して、補っています。`parodyinfo_df`に親フォルダまで含めるようにしてもよいのですが、ファイル置き場を柔軟に移動させたくなることもあるかと思い、今はこのようにしています。

```Python
from pathlib import Path
parodyinfo_df = pd.read_csv("output/parodyinfo.csv")

OUT_PATH = Path("output/figs")
OUT_PATH.mkdir(parents=True, exist_ok=True)

picture_dir = Path("figs/")
for i, (index, row) in enumerate(parodyinfo_df.iterrows()):
  picture_path = str(picture_dir.joinpath(row["picture_path"]))
  parodytext, originaltext = row["parodytext"], row["originaltext"]
  print(picture_path, parodytext)
  image = generate_picture(picture_path, originaltext, parodytext)
  image.save(str(OUT_PATH.joinpath("{}.png".format(i))), quality=75)
```

これで`output/figs`下に画像ファイルが生成されます。


# おわりに
今回で動画の材料となる静止画を生成することができました。
画像、文字の表示位置（順番）などは関数内部で固定してしまっているので、少し柔軟性に駆ける課題があります。情報、タイプ（画像か文字か）、座標を入力として任意の個数受け取れるようにできればもう少し柔軟性は上がると思います。今後の課題とします。

次はこの静止画をつなぎ合わせて動画を作ります。

# 参考にさせていただいた記事
* [OpenCVやPILで画像を表示させる（だけ）の話](https://qiita.com/mo256man/items/8912934578025adb5ce5)
* [Python, Pillowで画像に別の画像を貼り付けるpaste](https://note.nkmk.me/python-pillow-paste/)
* [Python, Pillowで文字（テキスト）を描画、フォント設定](https://note.nkmk.me/python-pillow-imagedraw-text-imagefont/)
* [【Python】Pillow ↔ OpenCV 変換](https://qiita.com/derodero24/items/f22c22b22451609908ee)
    * 今回は使っていないが、これがあれば、画像ファイル出力を経由しなくても動画を作れそう