やりたいこと
まず揺らす対象の画像を用意します
これは僕が適当に描いた電車の車内です
走行中の音源はこちらを使います
この音源に合わせて先ほどの画像を良い感じに揺らそうというのが今回の目的です
↓こんな感じになります
電車の走行音に合わせて揺らすプログラム組んだ pic.twitter.com/HAB08Wcbto
— 遠瀬緑 (@TMidry5) August 28, 2025
やること
揺らすために以下の二つの処理をします
- 音源を読み込んでデータを最大値で正規化する
- 乱数で揺らす方向のベクトルを生成して揺らす
1.音源を読み込んでデータを最大値で正規化する
librosaを使います
import librosa
class AudioInformation:
def __init__(self, audio_file):
self.data, self.sr = AudioInformation.parse_audio(audio_file)
self.length = librosa.get_duration(y=self.data, sr=self.sr)
self.pre_idx = 0
def get_data_at(self, sec):
idx = int(self.sr * sec)
res = max(self.data[self.pre_idx:idx+1])
self.pre_idx = idx + 2
return res
@staticmethod
def parse_audio(audio_file):
y, sr = librosa.load(audio_file)
return abs(y) / max(abs(y)), sr
get_data_at
は $直前に指定した秒数 < t \leqq 指定した秒数$の区間での正規化済データの最大値を返します
ピンポイントのデータを取ると微妙に音源と合わないということになるのでやらない方がいいです
2.乱数で揺らす方向のベクトルを生成して揺らす
「向き」と「大きさ」を指定すればベクトルになります
向きをx軸正方向とのなす角$\theta$で指定し、大きさを$r$とすると
$x = r\cos\theta$
$y = r\sin\theta$
ですので、$r$と$\theta$の二つを乱数で指定してあげればいいことが分かります
まず$r$について考えます
単純に揺れの大きさは音の強さに比例すると考えて、先ほどの音源データがちょうと$0 \leqq d \leqq 1$ですので、これを係数にしてみましょう
揺れの最大値を$S$、$d$をget_data_at
の返り値とすると
$r = S \times d$
です
$r$の生成はこれでいいとして、次は$\theta$の生成について考えます
いくらランダムな方向に揺らすとは言え、横方向への揺らしは何だか変な感じがします
加えて、直前の揺れと同じ方向へ揺らすのも変です
以上を踏まえて、$\theta$の生成は以下のように考えました
\theta = \left\{
\begin{array}{ll}
rand(\frac{\pi}{4}, \frac{3}{4}\pi) & frame \equiv 0 (\mod 2) \\
rand(\frac{5}{4}\pi, \frac{7}{4}\pi) & frame \equiv 1 (\mod 2)
\end{array}
\right.
以上で乱数生成部は終わりです
次は実際にそのベクトルに沿って画像を移動させるのですが、これにアフィン変換を使います
アフィン変換は、簡単に言うと以下の式で表される操作です
\begin{pmatrix}
x\prime \\
y\prime \\
1
\end{pmatrix}
=
\begin{pmatrix}
x_0 & x_1 & x_2 \\
y_0 & y_1 & y_2 \\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
1
\end{pmatrix}
この式一つで移動、回転、変形などの操作が出来るというんですから、凄いですよね
ちなみに三行目は使いません
じゃあなんであんだよと思うかもしれませんが、$T = Ax$の形で書くための必要経費とでも思ってください
まあ今回は移動しかやらないので、以下の形を使用します
\begin{pmatrix}
x\prime \\
y\prime \\
1
\end{pmatrix}
=
\begin{pmatrix}
1 & 0 & \Delta x \\
0 & 1 & \Delta y \\
0 & 0 & 1
\end{pmatrix}
\begin{pmatrix}
x \\
y \\
1
\end{pmatrix}
コードを書く
以上を踏まえてコードを書きます
import cv2
import numpy as np
import librosa
from moviepy.video.io.VideoFileClip import VideoFileClip
from moviepy.audio.io.AudioFileClip import AudioFileClip
from PIL import Image
import math
import os
import random
import sys
SLIGHT_VIB_SIZE = 9
MAX_LARGE_VIB_SIZE = 1
LARGE_VIB_BORDER = 0.3
FPS = 30
INTERVAL = 1000 / FPS
class AudioInformation:
def __init__(self, audio_file):
self.data, self.sr = AudioInformation.parse_audio(audio_file)
self.length = librosa.get_duration(y=self.data, sr=self.sr)
self.pre_idx = 0
def get_data_at(self, sec):
idx = int(self.sr * sec)
res = max(self.data[self.pre_idx:idx+1])
self.pre_idx = idx + 2
return res
@staticmethod
def parse_audio(audio_file):
y, sr = librosa.load(audio_file)
return abs(y) / max(abs(y)), sr
def cvt2Image(data):
return Image.fromarray(cv2.cvtColor(data, cv2.COLOR_BGR2RGB))
def shift(data, dx, dy):
mat = np.float32([[1, 0, dx], [0, 1, dy]])
return cvt2Image(cv2.warpAffine(data, mat, data.shape[:2][::-1]))
def anim_shift(data, dx, dy):
res = []
for i in range(5, 11, 5):
r = i / 10
res.append(shift(data, dx * i, dy * i))
res += res[:-1][::-1]
return res
def _shake(raw_image, frames, audio_info):
while True:
try:
data = audio_info.get_data_at(len(frames) / FPS)
except ValueError:
break
if len(frames) % 2 == 0:
t = random.uniform(math.pi / 4, math.pi * 3 / 4)
else:
t = random.uniform(math.pi * 5 / 4, math.pi * 7 / 4)
if LARGE_VIB_BORDER < data:
size = MAX_LARGE_VIB_SIZE * data
res = anim_shift(raw_image, size * math.cos(t), size * math.sin(t))
frames.extend(res)
else:
size = SLIGHT_VIB_SIZE * max([data, 0.1])
res = shift(raw_image, size * math.cos(t), size * math.sin(t))
frames.append(res)
for _ in range(2):
frames.append(cvt2Image(raw_image.copy()))
def shake(image_file, audio_info):
raw_image = cv2.imread(image_file)
frames = []
_shake(raw_image, frames, audio_info)
dst = os.path.splitext(image_file)[0] + ".gif"
src = cvt2Image(raw_image)
src.save(dst, save_all=True, append_images=frames, duration=INTERVAL, loop=0)
return dst
def main():
"""
第一引数: 揺らす画像
第二引数: 走行音
"""
a = AudioInformation(sys.argv[2])
gif = shake(sys.argv[1], a)
clip = VideoFileClip(gif)
audio = AudioFileClip(sys.argv[2]).subclipped(0, clip.duration)
clip.with_audio(audio).write_videofile(os.path.splitext(sys.argv[1])[0] + ".mp4")
if __name__ == "__main__":
main()
小揺れと大揺れで揺れの最大値を変えてます
この辺りは実際に結果を見ながら調整しました
また、音源データがどの大きさから「大揺れ」とするかのボーダーも色々見ながら良い感じのにしました
あんまり揺れが大きすぎると画面酔いするのでほどほどの値にしておきましょう
あと大揺れの時は変化が大きいので、アニメーション的な変化をさせてます(ここ、フレーム数の偶奇が変わるので、直後が同じ方向に動いてしまうのですが、見なかったことにします)
結論(?)
Pythonって、便利ですね