砂嵐を作る — Python + OpenCV + ffmpeg でノイズ映像と音声を生成する

砂嵐を作る — Python + OpenCV + ffmpeg でノイズ映像と音声を生成する — 砂嵐, Python, OpenCV AI動画

こんにちは、パレイド技術部です。

前回まではテレビのある部屋を生成し、画面の座標を確定させるところまで進めました。

第5回は、その画面に映す「コンテンツ」の制作です。ブラウン管テレビといえば、やはり砂嵐。昔は誰もが知っていた、あのザラザラしたノイズ映像と「ザー」というノイズを、Python で生成していきます。

何を作るか

最終的な成果物は 砂嵐の mp4 ファイルです。

[映像] Python + OpenCV → ノイズフレーム連番 → mp4v コーデック
                                                ↓
[音声] Python + numpy → ホワイトノイズ .wav     → ffmpeg で結合
                                                ↓
                                          tv_static.mp4(映像+音声)

生成AIでも動画は生成できますが、ちょっと記憶とは違ったものになりがち。今回は Python を使って、映像は OpenCV の VideoWriter で生成し、音声は numpy + wave モジュールで WAV を書き出した後、ffmpeg で結合します。

ChatGPTが生成した砂嵐のイメージ。ちょっとイメージと違う…

映像:多層ノイズで砂嵐を描く

砂嵐は単純なランダムで白黒のドットを置くだけでも一応それっぽくなりますが、実際のブラウン管の砂嵐にはもう少し構造があります。粗いブロックノイズ、細かい粒状ノイズ、スキャンラインの揺らぎなどが重なって、あの独特の質感が生まれています。

ノイズフレーム生成

import cv2
import numpy as np

DOT_SIZE = 4
DOT_WEIGHT = 0.70
MEMORY_PERSISTENCE = 0.12

def make_clean_frame(w: int, h: int, t: float) -> np.ndarray:
    """砂嵐の1フレームを生成する。

    Returns:
        (h, w, 3) uint8 BGR フレーム
    """
    # 1. 粗いブロックノイズ — 半分の解像度で生成してNearest Neighborで拡大
    base = np.random.normal(0.5, 0.25, (h // 2, w // 2, 1)).astype(np.float32)
    base = cv2.resize(np.clip(base, 0, 1), (w, h), interpolation=cv2.INTER_NEAREST)
    if base.ndim == 2:
        base = base[..., None]

    # 2. ドットレイヤー — DOT_SIZE ピクセル単位のグリッドノイズ
    gh = max(1, h // DOT_SIZE)
    gw = max(1, w // DOT_SIZE)
    dot_low = np.random.normal(0.5, 0.25, (gh, gw, 1)).astype(np.float32)
    dot = cv2.resize(np.clip(dot_low, 0, 1), (w, h), interpolation=cv2.INTER_NEAREST)
    if dot.ndim == 2:
        dot = dot[..., None]

    # 3. 細かいノイズ — ピクセル単位のランダム
    fine = np.clip(np.random.normal(0.5, 0.35, (h, w, 1)), 0, 1).astype(np.float32)

    # 4. ブレンド — 粗い+細かい → ドット層と合成
    static = 0.55 * base + 0.45 * fine
    static = DOT_WEIGHT * dot + (1.0 - DOT_WEIGHT) * static

    # 5. 横縞の揺らぎ — sin波で微小な明暗を追加
    y = np.arange(h, dtype=np.float32).reshape(h, 1, 1)
    static = np.clip(static + 0.04 * np.sin(2 * np.pi * (y / 10.0 + t * 2.0)), 0, 1)

    # 6. 垂直方向の微小ジッター — 1px のランダムシフト
    static = np.roll(static, np.random.choice([-1, 0, 1]), axis=0)

    # 3ch化してuint8に変換
    frame = np.repeat(static, 3, axis=2)
    return np.clip(frame * 255, 0, 255).astype(np.uint8)

ポイントは多層構造です。

レイヤー解像度役割
base(粗いブロック)元の半分大きな明暗のムラ
dot(ドット)DOT_SIZE px 単位中間スケールの粒状感
fine(細かいノイズ)ピクセル単位細かなザラつき
横縞揺らぎsin波スキャンラインの存在感
垂直ジッター1px シフト画面のブレ

単一のランダムノイズだとデジタル的な均一さが出てしまいますが、複数スケールを重ねることでアナログっぽい不均一さが生まれます。

映像:フレームを繋げて動画にする

フレーム単体は静止画ですが、連番で書き出して動画にします。フレーム間で「残像」と「輝度フリッカー」を加えると、動きに自然さが出ます。

from pathlib import Path

def generate_video(
    output: str | Path,
    width: int = 640,
    height: int = 480,
    fps: int = 30,
    secs: int = 10,
) -> Path:
    """砂嵐ノイズ動画を生成する。"""
    output = Path(output)
    output.parent.mkdir(parents=True, exist_ok=True)

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    writer = cv2.VideoWriter(str(output), fourcc, fps, (width, height))

    prev = None
    total_frames = fps * secs

    for i in range(total_frames):
        t = i / fps
        f = make_clean_frame(width, height, t)

        # フレーム残像 — 前フレームを12%ブレンドして時間的な滑らかさを加える
        f_float = f.astype(np.float32)
        if prev is None:
            smoothed = f_float
        else:
            smoothed = (1.0 - MEMORY_PERSISTENCE) * f_float + MEMORY_PERSISTENCE * prev
        prev = smoothed

        # 輝度フリッカー — 0.95〜1.05倍のランダム乗算
        smoothed = np.clip(smoothed * (0.95 + 0.10 * np.random.rand()), 0, 255)
        writer.write(smoothed.astype(np.uint8))

    writer.release()
    return output

MEMORY_PERSISTENCE = 0.12 は「前フレームの12%が残る」ことを意味します。これにより、各フレームが完全にランダムにならず、実際のブラウン管のように少し前の映像が残像として見えます。

輝度フリッカーは 0.95〜1.05 の範囲でフレーム全体の明るさをランダムに変動させます。わずかな変動ですが、「画面がちらつく」印象を与える重要な要素です。

音声:ホワイトノイズを生成する

砂嵐映像だけでは半分。あの「ザー」というノイズ音も作ります。基本はホワイトノイズですが、そのままだと高音域が耳に刺さるため、ローパスフィルタで丸めます。

import wave
import struct

def generate_noise_audio(
    output: str | Path,
    duration_sec: float = 10.0,
    sample_rate: int = 44100,
    amplitude: float = 0.3,
    cutoff_hz: float = 8000.0,
) -> Path:
    """ブラウン管テレビ風のノイズ音声を WAV で生成する。"""
    output = Path(output)
    n_samples = int(sample_rate * duration_sec)

    # ホワイトノイズ生成
    noise = np.random.normal(0, amplitude, n_samples).astype(np.float32)

    # 簡易ローパスフィルタ(指数移動平均)
    alpha = 2 * np.pi * cutoff_hz / sample_rate
    alpha = min(alpha / (alpha + 1), 1.0)
    filtered = np.zeros_like(noise)
    filtered[0] = noise[0]
    for i in range(1, n_samples):
        filtered[i] = alpha * noise[i] + (1 - alpha) * filtered[i - 1]

    # 60Hz のハム音を微かに加える(ブラウン管の電源由来)
    t = np.arange(n_samples) / sample_rate
    hum = 0.02 * np.sin(2 * np.pi * 60 * t)
    filtered = np.clip(filtered + hum, -1, 1)

    # WAV 書き出し(16bit モノラル)
    pcm = (filtered * 32767).astype(np.int16)
    with wave.open(str(output), "w") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        wf.writeframes(pcm.tobytes())

    return output

ポイントは2つです。

ローパスフィルタ: ホワイトノイズは全周波数が均等ですが、実際のテレビの砂嵐音はもう少しこもった音です。指数移動平均で高域をカットすると、実物に近い「ザー」になります。cutoff_hz を下げるほどこもった音になります。

60Hz ハム音: ブラウン管テレビは電源周波数(50Hz または 60Hz)のハム音が微かに聞こえることがあります。振幅 0.02 とごく小さいですが、加えることで「電気を使っている機械」の雰囲気が出ます。

ffmpeg で映像と音声を結合する

映像(mp4)と音声(wav)を ffmpeg で1本の mp4 にまとめます。

import subprocess

def combine_video_audio(
    video_path: str | Path,
    audio_path: str | Path,
    output_path: str | Path,
) -> Path:
    """ffmpeg で映像と音声を結合する。"""
    output_path = Path(output_path)
    cmd = [
        "ffmpeg", "-y",
        "-i", str(video_path),
        "-i", str(audio_path),
        "-c:v", "libx264",
        "-preset", "medium",
        "-crf", "23",
        "-c:a", "aac",
        "-b:a", "128k",
        "-shortest",
        str(output_path),
    ]
    subprocess.run(cmd, check=True, capture_output=True)
    return output_path

-shortest は映像と音声の長さが微妙にずれた場合に短い方に合わせるオプションです。-c:v libx264 で H.264 に再エンコードしています。OpenCV の mp4v コーデックのままだと互換性に問題が出ることがあるため、ffmpeg で再エンコードすると安心です。

まとめて実行する

ここまでの処理をまとめたエントリポイントです。

def generate_static_mp4(
    output: str | Path = "tv_static.mp4",
    width: int = 640,
    height: int = 480,
    fps: int = 30,
    secs: int = 10,
) -> Path:
    """砂嵐の映像+音声を生成し、1本のmp4にまとめる。"""
    output = Path(output)
    tmp_video = output.with_suffix(".video.mp4")
    tmp_audio = output.with_suffix(".wav")

    print("Generating noise video...")
    generate_video(tmp_video, width, height, fps, secs)

    print("Generating noise audio...")
    generate_noise_audio(tmp_audio, duration_sec=secs)

    print("Combining with ffmpeg...")
    combine_video_audio(tmp_video, tmp_audio, output)

    # 一時ファイルを削除
    tmp_video.unlink(missing_ok=True)
    tmp_audio.unlink(missing_ok=True)

    print(f"Done: {output}")
    return output
python generate_static.py --output tv_static.mp4 --width 640 --height 480 --secs 10

640×480、30fps、10秒の砂嵐動画が生成されます。サイズは数MB程度です。

生成された砂嵐の動画。掲載用に加工しています。

パラメータの調整ガイド

生成される砂嵐の見た目と音は、パラメータで大きく変わります。

パラメータ効果おすすめ範囲
DOT_SIZEドットの粗さ。大きいほどブロッキー2〜8
DOT_WEIGHTドット層の割合。高いほど粗い印象0.5〜0.8
MEMORY_PERSISTENCE残像の強さ。高いほど滑らか0.05〜0.20
cutoff_hzノイズ音の高域カット。低いほどこもる4000〜12000
amplitudeノイズ音の音量0.1〜0.5

見た目の好みに合わせて調整してみてください。特に DOT_SIZEMEMORY_PERSISTENCE は印象が大きく変わります。

まとめ

第5回では、テレビ画面に映す「砂嵐」コンテンツを Python だけで作りました。

  1. 映像: 多層ノイズ(粗いブロック + ドット + 細粒)+ 横縞揺らぎ + 残像 + 輝度フリッカー
  2. 音声: ホワイトノイズ + ローパスフィルタ + 60Hz ハム音
  3. 結合: ffmpeg で H.264 + AAC の mp4 に仕上げ

外部素材に頼らず、コードだけで砂嵐の映像と音声の両方を生成できるのは、パイプラインの自動化において大きな利点です。

次回(第6回)では、第4回で確定したスクリーン座標と今回作った砂嵐 mp4 を組み合わせて、テレビ画面に砂嵐を合成するパイプラインを実装します。

タイトルとURLをコピーしました