Bad Apple!! 影絵MVをスペクトログラムで見る
はじめに
ニコニコ動画では 2900 万回以上再生されている、【東方】Bad Apple!! PV【影絵】という有名な動画がありますよね。今でも不定期で見に行きますが、何回見たり聞いたりしても飽きないです。
最近は、「Bad Apple!! 影絵を○○で再生してみた」といった、MV を色々な形で再現する動画が投稿されています。その中でも、特に印象に残っているものを 3 つ紹介します。
VSCode のコードフォーマッタを使ったもの、Windows10 のタスクマネージャを使って表現したもの、そして東方弾幕シューティングにしてみたものです。
プログラミングを勉強していると、MV の再現で何か 1 つ作れるんじゃないかと考えることがあります。そして、今回ついに手を出してみようと思い、記事を書き始めてみました。方法としては、記事タイトルにもあるように、スペクトログラムで MV を見れるようにしてみます。完成すると、スペクトログラムを表示出来る Audacity などのソフトを用いて、このような感じになります。
ソースコード
作成した Jupyter Notebook を Hiroya-W/Bad_Apple_Spectrogram の GitHub リポジトリに公開しています。
ニコニコ動画から動画を取得する
youtube-dl の fork プロジェクトである yt-dlp/yt-dlp を利用します。ニコニコ動画にも対応していて、Python 環境があればパッケージをインストールすれば、使うことができます。
yt-dlp -o "sm8628149.mp4" https://www.nicovideo.jp/watch/sm8628149
コマンドを実行して、sm8628149.mp4
が取得できます。
動画からフレームを取り出す
動画の読み込み、画像ファイルへの出力には OpenCV を利用します。1 フレームずつ読み込んで、画像として出力させる感じです。
import cv2
import librosa
import librosa.display
import matplotlib.pyplot as plt
import numpy as np
import os
from scipy import hamming
import soundfile as sf
from tqdm.notebook import tqdm
VIDEO_PATH = "sm8628149.mp4"
FLAME_DIR = "frames"
cap = cv2.VideoCapture(VIDEO_PATH)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
num_of_digit = len(str(frame_count))
for i in tqdm(range(frame_count)):
is_image, frame_img = cap.read()
if is_image:
# 0000.jpg ~ 6573.jpgのファイル名にする
frame_path = os.path.join(VIDEO_PATH, str(i).zfill(num_of_digit) + ".jpg")
cv2.imwrite(frame_path, frame_img)
cap.release()
スペクトログラムに画像を埋め込む
今回の技術のキモとなる部分です。「スペクトログラムに画像を埋め込む」とか強そうなフレーズを書きましたが、やることは単純で、「画像の輝度をスペクトログラムとして扱い、音声ファイルを作成する」が出来れば OK です。
音声ファイルから、スペクトログラムを表示するためには、短時間フーリエ変換(STFT)を用いて処理します。逆に、スペクトログラムから音声ファイルを作るには逆短時間フーリエ変換(iSTFT)すればいいです。
ここでは、Python の音声処理ライブラリである librosa/librosaを使い、librosa で実装されている STFT/iSTFT を利用します。
SAMPLING_RATE = 22050
def img_to_wav(img: np.ndarray) -> np.ndarray:
img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
# 画像の表示では原点は左上、スペクトログラムの表示では原点は左下なので合わせておく
img_flip = cv2.flip(img_gray, 0)
img_float = img_flip.astype(np.float32)
# iSTFTでスペクトログラムから音声に変換
wav_data = librosa.istft(img_float)
return wav_data
def wav_to_spectrogram(
wav_data: np.ndarray, n_fft=1024, hop_length=256, win_length=1024, window=hamming
) -> np.ndarray:
wav_stft = librosa.stft(
wav_data,
n_fft=n_fft,
hop_length=hop_length,
win_length=win_length,
window=window,
)
spec, _ = librosa.magphase(wav_stft)
spec_db = librosa.amplitude_to_db(spec)
return spec_db
SAMPLE_IMG_PATH = "frames/1000.jpg"
img = cv2.imread(SAMPLE_IMG_PATH)
wav = img_to_wav(img)
spec_db = wav_to_spectrogram(wav)
fig, axs = plt.subplots(1, 2, figsize=(10, 5), tight_layout=True)
axs[0].imshow(img, cmap="gray")
axs[0].set_title("Original Image")
librosa.display.specshow(
spec_db, sr=SAMPLING_RATE, x_axis="time", y_axis="linear", ax=axs[1], cmap="magma"
)
axs[1].set_title("Spectrogram")
元の画像では東方キャラの部分は白か黒に塗りつぶされているので、スペクトログラムでもそうなると予想していたのですが、結果としてはエッジ検出したようになってしまいました。
これの原因を掴めていないのですが、この記事ではこのまま進めることにします[1]。
各フレームに対して処理し、音声ファイルとして書き出して完成です。
PER_FRAME = 100
wav_unite = np.zeros(shape=(0,), dtype=np.float32)
# 各フレームを処理し、1つのwavデータにまとめる
for i in tqdm(range(0, frame_count, PER_FRAME)):
img_path = os.path.join(FLAME_DIR, str(i).zfill(num_of_digit) + ".jpg")
img = cv2.imread(img_path)
wav = img_to_wav(img)
wav_unite = np.concatenate((wav_unite, wav))
# wavを書き出す
with sf.SoundFile("output.wav", mode="w", samplerate=SAMPLING_RATE, channels=1) as f:
f.write(wav_unite)
スペクトログラムを表示する
ここでは、Audacity を用いて、スペクトログラムを表示します。作成した音声ファイル読み込み、ファイル名の表示からスペクトログラムを表示させる事ができます。
そのままでは周波数軸が対数になっているので、線形で表示させれば綺麗に表示出来ます。
後は、ウィンドウサイズ、再生速度を調整すれば動画と並べて再生することも出来そうです。
もちろん、音声ファイルなので音も再生することが出来ます。音声ファイルとして出力したことで得られる面白いポイントですね。
周波数が連続して変化していくのでスイープ音になるのですが、高周波数成分によりキーンとした音が再生されるため、音量を下げて聞いた方が良いです。
おわりに
ほとんどライブラリを使ったコードで、サクッと実装出来てしまいました。
思った以上にビジュアルの良いものが生成出来たのと、1 度やってみたかったことが出来て、楽しかったです。後は元動画と並べて再生して動画として残しておこうと考えています。
スペクトログラムが画像のエッジ検出したような結果になることは、少し勉強してみます。
詳しい方いらっしゃいましたら、情報頂ければ幸いです。
この記事はあくあたん工房 Advent Calendar 2021 9 日目の記事でした。
-
ズラしながら窓関数をかけて足し合わせる処理で、上手く打ち消されたりするんでしょうか...?ちゃんと数式と処理を追ってみたいポイントです。STFT、興味深いですね。 ↩︎
Discussion