見出し画像

くしゃみシーンで差をつける切り抜きチャンネルの裏側【運営レポート1週目】

皆様ハロー、小銭稼ぎ系エンジニアのスマイルです('ω')ノ

先週から始めたVTuberの切り抜きチャンネルなんですが、いきなり調査不足&見切り発車だった事が発覚し根底から作り直しています( ˘•ω•˘ )

前回のレポートはこちら。

動画の投稿以前にコンテンツの方向性から見直すことになった1週目のレポートをまとめました。

記事が面白かったらフォロー&♥よろしくお願いしますm(_ _)m


日次レポート

当初の予定では「面白いシーンを自動抜き出し&AIによる自動吹き替え」を自動化して低コストでチャンネル運営する計画だったんですが、コスト面もクオリティ面も続けられるようなレベルに達していなかった事が発覚。

チャンネルは作っちゃいましたが、低コストで動画コンテンツを制作するプロセスから練り直す事に。


2月1日

当初の計画通り切り抜き動画作成する。

作業効率を上げるために工程別に作業をまとめて行おう、まずは面白そうな動画をいくつかダウンロードするか。

動画を7本ほどダウンロードしただけで数時間も経過してしまった、当初の計画から10倍くらい時間かかってるんだけど。見積もり甘すぎ笑

当然このペースでは1ヶ月分の切り抜き動画を半日程度で作れるわけもなく、残りの作業が翌日へ持ち越しに。


2月2日

動画を自動で切り抜けるようになったのはいいんだけど、膨大な動画ファイルをすべて目視でチェックするのは現実的じゃなかった。

なので、面白動画を自動で検出するための評価指標①テキストの感情解析・②音声の特徴量分析を追加した。

この二つの指標を動画のファイル名に追加しまして、いちいち動画を開いて目視チェックしなくても動画の面白さを判定出来たらいいなという試み。


2月3日

評価指標を追加した事で処理時間がさらに伸びてしまった、1時間の動画を自動で分割するだけで4時間くらいかかってしまう。

切り抜き動画作成の作業工程が多すぎるので、クオリティ下がってもいいから低コスト化を進める。

  • AIによる日本語吹き替えをやめた。

  • 縦型へのリサイズをやめた。

これで動画公開までの時間は大分短縮されたハズ、切り抜き動画を作成する時間が長くなっちゃったのでローカルPCを24時間ブン回し中。


2月4日

色々検証してみたけど、今の仕組みだと面白シーンのピックアップを自動化する事は無理っぽい。ある程度は出来るけど、目視チェック不要になるほどのクオリティは出ない。

面白シーンの切り抜きを自動化するなら誰かに外注するか、既存の切り抜き動画をパクるしかなさそうだな。

面白シーンで切り抜き動画作るの辞めた!

ランニングコストが高いと絶対に続かないから、もっとシステマチックに作れて需要のある切り抜き動画を作ろう。例えば、

  • くしゃみ

  • あくび

  • 絶叫、びっくり

  • 特定のキーワード(下ネタ、センシティブなど)

こんな要素を機械的に集めて1本の動画にまとめて投稿するチャンネルに方向性を変えよう。


2月5日

youtubeの音声データを解析して動画内からくしゃみ音を検出するスクリプトを作る。

pythonによる異音検知を調べてみると、くしゃみの音をたくさん集めて機械学習させてくしゃみ検出モデルを作る方法が一般的っぽい。

もうちょっと簡単な方法ないかな。

下記の動画からオーディオファイルを抽出して検証に使おう。

とりあえず短くて大きい音を検出してみる。

import librosa
import matplotlib.pyplot as plt
import numpy as np

# 音声ファイルの読み込み
y, sr = librosa.load("output_audio.wav", sr=None)

# 短時間エネルギーの計算
hop_length = 512
frame_length = 2048
energy = np.array([
    sum(abs(y[i:i+frame_length]**2))
    for i in range(0, len(y), hop_length)
])

# エネルギーの平滑化
energy_smoothed = np.convolve(energy, np.ones(10)/10, mode='same')

# 閾値を設定して高エネルギーのフレームを検出
threshold = np.mean(energy_smoothed) * 2
high_energy_frames = np.where(energy_smoothed > threshold)[0]

# 時間に変換
times_high_energy = librosa.frames_to_time(high_energy_frames, sr=sr, hop_length=hop_length)

# 波形のプロット
plt.figure(figsize=(14, 6))
times = np.arange(len(y)) / float(sr)
plt.plot(times, y, label="Waveform", alpha=0.5)

# くしゃみと思われる箇所のマーキング(50%透明度で)
for t in times_high_energy:
    plt.axvline(x=t, color='r', linestyle='--', alpha=0.5)

plt.title('Waveform and Detected Sneeze Locations')
plt.xlabel('Time (seconds)')
plt.ylabel('Amplitude')
plt.legend()

# 画像を保存
plt.savefig("detected_sneeze_locations.png")

# 検出したくしゃみの時間をターミナルに表示
print("Detected sneeze times (seconds):")
for t in times_high_energy:
    print(f"{t:.2f}")

可視化された結果がこちら。

方向性は間違って無さそうだけど、単純に大きいBGMなどに反応している箇所も多いので音声のみ抜き出してみる。前に使ったspleeterで分離しよう。

うーん、なぜかエラーが出てspleeterが上手く動かない。。。


2月6日

他の音源分離ライブラリdemucsを試してみる。

pip install demucs
import demucs.separate

options = [
    "output_audio.wav",
    "-n", "htdemucs",
    "--two-stems", "vocals",
    "--mp3"
    ]
demucs.separate.main(options)

出力結果がこちら。

くしゃみが音声として認識されていない笑、そりゃそうか。どっかにくしゃみの音を集めたデータセットないもんかね。

あったーー!くしゃみの音いっぱい見つけた。

だめだ、おっさんのくしゃみばっかりだ。

昨日作った異音検出スクリプトで適当にそれっぽい音集めて、目視でくしゃみだけラベリングするのが一番マシかな。

◆ maybe_sneeze.py

import librosa
import matplotlib.pyplot as plt
import numpy as np
from pytube import YouTube
from moviepy.editor import *
import matplotlib.pyplot as plt
import librosa.display

# ステップ1: YouTubeの動画をダウンロード(ビデオを含む)
video_url = 'https://www.youtube.com/watch?v=KNJm4qymLMM'
yt = YouTube(video_url)
video = yt.streams.filter(file_extension='mp4').first()
downloaded_file = video.download()

# 動画IDの抽出
video_id = yt.video_id

# ステップ2: ダウンロードした動画から音声を抽出
audio_clip = AudioFileClip(downloaded_file)
audio_clip.write_audiofile("output_audio.wav")
audio_clip.close()

# 音声ファイルの読み込み
y, sr = librosa.load("output_audio.wav", sr=None)

# 短時間エネルギーの計算
hop_length = 512
frame_length = 2048
energy = np.array([
    sum(abs(y[i:i+frame_length]**2))
    for i in range(0, len(y), hop_length)
])

# エネルギーの平滑化
energy_smoothed = np.convolve(energy, np.ones(10)/10, mode='same')

# 閾値を設定して高エネルギーのフレームを検出
threshold = np.mean(energy_smoothed) * 1
high_energy_frames = np.where(energy_smoothed > threshold)[0]

# 時間に変換
times_high_energy = librosa.frames_to_time(high_energy_frames, sr=sr, hop_length=hop_length)

# くしゃみの区間をまとめる
def group_sneeze_times(times, gap_threshold=0.3):
    groups = []
    current_group = [times[0]]

    for time in times[1:]:
        if time - current_group[-1] <= gap_threshold:
            current_group.append(time)
        else:
            groups.append(current_group)
            current_group = [time]
    groups.append(current_group)

    # 各グループの最小値(開始時間)と最大値(終了時間)を計算
    sneeze_intervals = [(min(group), max(group)) for group in groups]
    return sneeze_intervals

# くしゃみ区間をまとめる
sneeze_intervals = group_sneeze_times(times_high_energy)

# 波形のプロット
plt.figure(figsize=(14, 6))
times = np.arange(len(y)) / float(sr)
plt.plot(times, y, label="Waveform", alpha=0.5)

# くしゃみと思われる区間のマーキング(50%透明度で)
for start, end in sneeze_intervals:
    plt.axvline(x=start, color='r', linestyle='--', alpha=0.5)
    plt.axvline(x=end, color='r', linestyle='--', alpha=0.5)
    plt.fill_betweenx([-1, 1], start, end, color='r', alpha=0.2)

plt.title('Waveform and Detected Sneeze Intervals')
plt.xlabel('Time (seconds)')
plt.ylabel('Amplitude')
plt.legend()

# 画像を保存
plt.savefig("detected_sneeze_intervals.png")

# 検出したくしゃみ区間の時間をターミナルに表示
print("Detected sneeze intervals (start, end):")
for start, end in sneeze_intervals:
    print(f"({start:.2f}, {end:.2f}) seconds")

# 分割した動画を保存するフォルダを指定
output_folder = "sneeze_clip"
if not os.path.exists(output_folder):
    os.makedirs(output_folder)

# ステップ3: 検出されたくしゃみ区間で動画を分割して保存
video_clip = VideoFileClip(downloaded_file)
for i, (start, end) in enumerate(sneeze_intervals, 1):
    sneeze_clip = video_clip.subclip(start, end)
    sneeze_clip_filename = os.path.join(output_folder, f"{video_id}_sneeze_clip_{i}.mp4")
    sneeze_clip.write_videofile(sneeze_clip_filename, codec="libx264", audio_codec="aac")
    print(f"Sneeze clip saved: {sneeze_clip_filename}")

スクリプトを修正して実行するとこんな感じになった。

くしゃみ以外も多く含まれてるけど取りこぼさない事が大事なので、とりあえずOK。チェックが面倒なら閾値をギリギリまで下げればいいし。

よし、このスクリプトで機械学習用のくしゃみデータを集めるか。


2月7日

チャンネル名を「ホロライブ AI吹き替えプロジェクト」→「ホロライブ助かる切り抜き」へ変更した。

投稿するコンテンツの方向性をガッツリ変更したので、不要になったAI吹き替えサービスelevenlabs.ioの有料プランを解約した。

くしゃみデータを本格的に集める前に異音検出の機械学習の練習しておく、ローカルPCでスペック的に学習できるか分からんし。

昨日のmaybe_sneeze.pyをちょっと改修して音声ファイルで保存するようにして1個ずつくしゃみかチェック。くしゃみの場合はファイル名に「sneeze」を付ける、これでラベリングとする。

よしよし、ファイル名にsneezeが含まれている音声ファイルと含まれていない音声ファイルで分類モデルを作る。

◆ sneeze_modeling.py

import os
import librosa
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report
from joblib import dump, load

# データの読み込みと前処理
def load_and_preprocess_data(directory):
    features = []
    labels = []
    # 指定されたディレクトリ内のファイルを走査
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith('.wav') or file.endswith('.mp3'):
                file_path = os.path.join(root, file)
                # 音声ファイルを読み込み
                y, sr = librosa.load(file_path, sr=None)
                # 特徴抽出(MFCC)
                mfcc = librosa.feature.mfcc(y=y, sr=sr)
                features.append(np.mean(mfcc, axis=1))
                # ラベル付け(ここではファイル名に'sneeze'が含まれる場合に1、それ以外は0とします)
                label = 1 if 'sneeze' in file.lower() else 0
                labels.append(label)
    return np.array(features), np.array(labels)

# モデルのトレーニングと評価
def train_and_evaluate(features, labels):
    # トレーニングとテストセットに分割
    X_train, X_test, y_train, y_test = train_test_split(features, labels, test_size=0.2, random_state=42)
    # モデルのトレーニング
    model = RandomForestClassifier(n_estimators=100, random_state=42)
    model.fit(X_train, y_train)
    # モデルの評価
    y_pred = model.predict(X_test)
    print(classification_report(y_test, y_pred))
    # モデルをファイルとして保存
    model_filename = 'models/sneeze_detection_model.joblib'
    dump(model, model_filename)
    print(f"Model saved to {model_filename}")
    return model

# モデルをロードする関数(必要に応じて使用)
def load_model(filename):
    return load(filename)

# メイン処理
if __name__ == "__main__":
    directory = 'sneeze_clip_audio'  # くしゃみのデータが格納されているディレクトリ
    features, labels = load_and_preprocess_data(directory)
    model = train_and_evaluate(features, labels)

くしゃみ分類モデルまでは出来た、これを使ってyoutubeの動画からくしゃみシーンを特定するスクリプトを作る。

◆ 2_sneeze_detector.py

'''
■ 調整可能なパラメータ
split_audio_into_chunks:chunk_duration
librosa.feature.mfcc:n_mfcc, n_fft, hop_length
merge_sneeze_timestamps:
'''
from pytube import YouTube
from moviepy.editor import AudioFileClip
import librosa
from joblib import load
import numpy as np
import os

def download_youtube_video(video_url):
    yt = YouTube(video_url)
    video = yt.streams.filter(file_extension='mp4').first()
    downloaded_file = video.download()
    return downloaded_file

def extract_audio_from_video(video_file, output_format='wav'):
    audio_clip = AudioFileClip(video_file)
    audio_filename = os.path.splitext(video_file)[0] + '.' + output_format
    audio_clip.write_audiofile(audio_filename)
    audio_clip.close()
    return audio_filename

def split_audio_into_chunks(audio_filename, chunk_duration=0.5): #chunk_durationでチャンクの秒数指定
    y, sr = librosa.load(audio_filename, sr=None)
    chunk_length = int(chunk_duration * sr)
    total_samples = len(y)
    chunks = [y[i:i+chunk_length] for i in range(0, total_samples, chunk_length)]
    return chunks, sr

def detect_sneeze_in_chunks(chunks, sr, model_filename):
    model = load(model_filename)
    features = [np.mean(librosa.feature.mfcc(y=chunk, sr=sr), axis=1) for chunk in chunks]
    predictions = model.predict(features)
    sneeze_timestamps = [i for i, prediction in enumerate(predictions) if prediction == 1]
    return sneeze_timestamps

def merge_sneeze_timestamps(sneeze_timestamps, chunk_duration):
    # 近接したくしゃみ検出を統合する
    merged_timestamps = []
    current_start = None
    current_end = None

    for timestamp in sneeze_timestamps:
        start = max(0, timestamp * chunk_duration - 1)  # 検出箇所から1秒前
        end = timestamp * chunk_duration + chunk_duration  # 検出箇所の終了

        if current_start is None:
            current_start, current_end = start, end
        elif start <= current_end:  # 新しい検出が現在の範囲と重なる場合
            current_end = end  # 範囲を更新
        else:
            merged_timestamps.append((current_start, current_end))
            current_start, current_end = start, end

    if current_start is not None:
        merged_timestamps.append((current_start, current_end))

    return merged_timestamps

def seconds_to_hms(seconds):
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60
    return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"

def main(video_url, model_filename):
    downloaded_file = download_youtube_video(video_url)
    audio_filename = extract_audio_from_video(downloaded_file)
    chunks, sr = split_audio_into_chunks(audio_filename)
    sneeze_timestamps = detect_sneeze_in_chunks(chunks, sr, model_filename)
    merged_timestamps = merge_sneeze_timestamps(sneeze_timestamps, 0.5)

    for start, end in merged_timestamps:
        start_hms = seconds_to_hms(start)
        end_hms = seconds_to_hms(end)
        print(f"Sneeze scene detected from {start_hms} to {end_hms}.")

    os.remove(downloaded_file)  # Optional: remove the downloaded video file
    os.remove(audio_filename)  # Optional: remove the extracted audio file

if __name__ == "__main__":
    video_url = 'https://www.youtube.com/watch?v=KNJm4qymLMM'
    model_filename = 'models/sneeze_detection_model.joblib'
    main(video_url, model_filename)

結果がこちら。

確認してみる。

動画の実際のくしゃみシーンは3ヶ所で00:05・00:21・00:25の辺りでほぼ正解してた、よしよし。

今やってるのは①自分の環境で分類モデルを作れるか、②モデルを用いて実際に運用できるか、の確認作業。これで一通り問題ない事が分かったので、くしゃみデータ収集&モデル精度の向上ステップに戻る。やりますかー。

とりあえず709個教師データを集めましてこの内、くしゃみは195個・関係ないのが514個。このまま雑に学習させてモデルを作ってみると正答率はおおよそ85%くらいになった、悪くない。

このモデルでアーカイブ動画からくしゃみシーンだけを抜き出してくれたら大成功、楽しくなってきた。

時短&効率化ツール

切り抜きチャンネルに限らず、YouTubeチャンネルを運営する上で役に立つツールを自作し公開しています。興味のある方はぜひご一読下さいm(_ _)m

チャンネル成績(開設1週目)

ここから先は

403字 / 1画像

スタンダード

¥990 / 月
初月無料
このメンバーシップの詳細

よろしければサポートお願いします、頂いたサポートは活動費として使用させて頂きより有意義な記事を書けるように頑張ります!