テレビに砂嵐を映す — スクリーン座標と映像合成パイプライン

テレビに砂嵐を映す — スクリーン座標と映像合成パイプライン — 砂嵐, 映像合成, ホモグラフィ AI画像

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

この連載では、ComfyUI で生成した背景画像のテレビ画面に映像を合成するパイプラインを構築しています。第4回では Blender のカメラ情報からスクリーン座標を JSON に書き出す仕組みを作り、第5回では砂嵐(スタティックノイズ)の mp4 を Python で生成しました。

今回はこの2つの成果物を接続します。screen_regions.json が持つスクリーン座標に向けて、砂嵐フレームをホモグラフィ変換で射影し、背景画像に合成するパイプラインを実装します。

パイプラインの流れ

全体のデータフローを整理します。

背景画像 (ComfyUI生成) + screen_regions.json + 砂嵐.mp4
    ↓
フレームごとに:
  1. 砂嵐mp4からフレーム読み込み (cv2.VideoCapture)
  2. screen_regions.jsonからスクリーン座標取得
  3. ホモグラフィ変換で砂嵐フレームをスクリーン形状に射影
  4. アルファブレンドで背景に合成
    ↓
合成済みフレーム連番 → cv2.VideoWriter → mp4出力

入力が3つ、出力が1つのシンプルな構成です。スクリーン座標は最初に1回読み込めばよく、フレームごとに変化するのは砂嵐の映像だけです。

最終的な合成の例は下記の通りです。中心がずれていますが、実際の生成画像自体がずれており、今回は一致していることが重要なので修正はしていません。

元画像。realVisXLで生成。

screen_regions.json の読み込み

第4回で Blender スクリプトが出力した JSON は以下のような構造を持っています。

{
  "image_size": [1920, 1080],
  "screens": [
    {
      "name": "TV_Screen_Main",
      "corners_normalized": [
        [0.312, 0.198],
        [0.687, 0.198],
        [0.687, 0.742],
        [0.312, 0.742]
      ]
    }
  ]
}

corners_normalized は画像サイズに対する正規化座標(0〜1)です。実際のピクセル座標に変換する処理を書きます。

import json
import numpy as np

def load_screen_regions(json_path: str, image_width: int, image_height: int):
    """screen_regions.json を読み込み、ピクセル座標に変換して返す。"""
    with open(json_path) as f:
        data = json.load(f)

    screens = []
    for screen in data["screens"]:
        corners_norm = np.array(screen["corners_normalized"], dtype=np.float32)
        corners_px = corners_norm * np.array([image_width, image_height], dtype=np.float32)
        screens.append({
            "name": screen["name"],
            "corners_px": corners_px,
        })
    return screens

正規化座標を使っている理由は、背景画像の解像度が変わっても JSON を書き直す必要がないためです。ComfyUI の出力解像度を 1920×1080 から 2560×1440 に変更しても、同じ JSON がそのまま使えます。

ホモグラフィ変換の実装

砂嵐フレームは長方形です。これをテレビ画面の四角形(パースがかかっているため台形に近い)に射影するには、ホモグラフィ変換(射影変換)を使います。

角の並び順を揃える

変換を正しく行うには、ソース側とデスティネーション側で角の対応が一致している必要があります。JSON から読み込んだ角の順序が保証されない場合に備えて、重心からの角度で並べ替える関数を用意します。

def sort_corners(corners: np.ndarray) -> np.ndarray:
    """4つの角を 左上→右上→右下→左下 の順に並べ替える。"""
    center = corners.mean(axis=0)
    angles = np.arctan2(corners[:, 1] - center[1], corners[:, 0] - center[0])
    # arctan2 の角度順: 左上が最も負 (左上方向 ≒ -3π/4 付近)
    # 角度でソートし、左上始点に回転する
    order = np.argsort(angles)
    sorted_pts = corners[order]
    # 上段2点のうちx小さい方を先頭にする
    top_two = sorted_pts[:2]
    if top_two[0, 0] > top_two[1, 0]:
        sorted_pts[0], sorted_pts[1] = sorted_pts[1].copy(), sorted_pts[0].copy()
    bottom_two = sorted_pts[2:]
    if bottom_two[0, 0] < bottom_two[1, 0]:
        sorted_pts[2], sorted_pts[3] = sorted_pts[3].copy(), sorted_pts[2].copy()
    return sorted_pts

射影変換とマスク生成

OpenCV の getPerspectiveTransform で変換行列を計算し、warpPerspective で砂嵐フレームを射影します。同時に、合成領域を示すマスクも生成します。

import cv2

def warp_to_screen(frame: np.ndarray, dst_corners: np.ndarray,
                   output_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]:
    """砂嵐フレームをスクリーン形状に射影し、(変換画像, マスク) を返す。"""
    h, w = frame.shape[:2]
    src_corners = np.array([
        [0, 0],
        [w, 0],
        [w, h],
        [0, h],
    ], dtype=np.float32)

    dst_sorted = sort_corners(dst_corners)
    M = cv2.getPerspectiveTransform(src_corners, dst_sorted)

    out_w, out_h = output_size
    warped = cv2.warpPerspective(frame, M, (out_w, out_h))

    # マスク: 白い矩形を同じ変換で射影
    mask_src = np.ones((h, w), dtype=np.uint8) * 255
    mask = cv2.warpPerspective(mask_src, M, (out_w, out_h))

    return warped, mask

warpPerspective はデスティネーション画像全体のサイズ(背景画像と同じ)を受け取り、スクリーン領域以外は黒(0)で埋めます。マスクも同様に、スクリーン領域だけが白になります。

フレームごとの合成処理

射影した砂嵐フレームとマスクを使い、背景画像に合成する関数です。

def composite_frame(bg: np.ndarray, static_frame: np.ndarray,
                    screen_corners_px: np.ndarray,
                    feather_radius: int = 0) -> np.ndarray:
    """背景画像に砂嵐フレームを合成して返す。"""
    h, w = bg.shape[:2]
    warped, mask = warp_to_screen(static_frame, screen_corners_px, (w, h))

    # エッジのフェザリング(任意)
    if feather_radius > 0:
        ksize = feather_radius * 2 + 1
        mask = cv2.GaussianBlur(mask, (ksize, ksize), 0)

    # アルファブレンドの計算
    alpha = mask.astype(np.float32) / 255.0
    alpha = alpha[:, :, np.newaxis]  # (H, W, 1) に拡張

    composited = (warped.astype(np.float32) * alpha
                  + bg.astype(np.float32) * (1.0 - alpha))
    return composited.astype(np.uint8)

feather_radius を 0 より大きくすると、マスクの境界にガウスブラーがかかり、合成の境目が目立ちにくくなります。テレビの枠に対してエッジがくっきりしすぎる場合に有効です。

動画全体の合成パイプライン

ここまでの部品を組み合わせて、動画全体を処理するメインパイプラインを構成します。

def compose_video(bg_path: str, static_path: str, regions_path: str,
                  output_path: str, feather_radius: int = 0,
                  brightness_factor: float = 1.0):
    """背景画像 + 砂嵐mp4 + スクリーン座標 → 合成動画を出力する。"""
    bg = cv2.imread(bg_path)
    if bg is None:
        raise FileNotFoundError(f"背景画像が見つかりません: {bg_path}")

    h, w = bg.shape[:2]
    screens = load_screen_regions(regions_path, w, h)

    cap = cv2.VideoCapture(static_path)
    if not cap.isOpened():
        raise FileNotFoundError(f"砂嵐動画を開けません: {static_path}")

    fps = cap.get(cv2.CAP_PROP_FPS)
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))

    fourcc = cv2.VideoWriter_fourcc(*"mp4v")
    writer = cv2.VideoWriter(output_path, fourcc, fps, (w, h))

    for i in range(total_frames):
        ret, static_frame = cap.read()
        if not ret:
            break

        # 明るさ調整(任意)
        if brightness_factor != 1.0:
            static_frame = np.clip(
                static_frame.astype(np.float32) * brightness_factor,
                0, 255,
            ).astype(np.uint8)

        # 全スクリーンに対して合成
        composited = bg.copy()
        for screen in screens:
            composited = composite_frame(
                composited,
                static_frame,
                screen["corners_px"],
                feather_radius=feather_radius,
            )

        writer.write(composited)

    cap.release()
    writer.release()

ポイントは以下の通りです。

  • 背景画像は1枚を使い回す: 毎フレーム bg.copy() で複製してから合成するため、元画像は変更されません。
  • 複数スクリーン対応: screens のリストをループするので、JSON に複数のテレビ画面が定義されていればすべてに砂嵐が合成されます。
  • 明るさ調整: brightness_factor を 0.7 程度にすると、暗い部屋のテレビらしい落ち着いた光り方になります。

(参考)ブレンドの工夫

基本の合成はマスクベースのアルファブレンドで十分ですが、より柔軟で、リアルな仕上がりにするための追加テクニックをいくつか紹介します。

エッジフェザリング

テレビの枠と砂嵐の境目がくっきりしすぎると不自然に見えることがあります。先ほどのコードにある feather_radius パラメータでマスクをぼかすことで、自然なグラデーションになります。

# feather_radius=3 の場合: 7x7 のガウスブラーがかかる
composited = composite_frame(bg, frame, corners, feather_radius=3)

値は 1〜5 程度で十分です。大きすぎるとテレビの枠からはみ出して見えるので注意が必要です。

明るさの調整

ComfyUI で生成した背景画像の照明環境と、砂嵐の明るさが合っていないと浮いて見えます。brightness_factor で全体の明るさを調整するほか、合成後にスクリーン領域だけトーンカーブを適用する方法もあります。

# スクリーン領域のみガンマ補正をかける例
gamma = 0.8
lut = np.array([((i / 255.0) ** gamma) * 255
                for i in range(256)], dtype=np.uint8)
warped_adjusted = cv2.LUT(warped, lut)

背景画像が暗めの部屋なら gamma を 0.7〜0.8 に、明るい部屋なら 1.0 のまま使うとバランスが取れます。

画面の光漏れ表現

さらにリアリティを追求するなら、テレビ画面から周囲に光が漏れる表現を加えることもできます。マスクを大きめに拡張し、低い不透明度でブラーをかけた砂嵐を重ねることで、グロー効果が得られます。ただしこれは次回以降の発展課題とします。

まとめ

今回は、第4回で取得したスクリーン座標(screen_regions.json)と第5回で生成した砂嵐 mp4 を使い、背景画像のテレビ画面に映像を合成するパイプラインを実装しました。

パイプライン自体は「ホモグラフィ変換 → マスク生成 → アルファブレンド」というシンプルな構成です。合成処理の本質的な難しさは少なく、むしろ難しかったのは第2〜4回で取り組んだスクリーン座標の取得でした。座標さえ正確に決まっていれば、合成は素直に実装できます。

次回は最終回です。ここまでに作った全パーツを組み合わせて完成サンプル動画を生成し、連載全体を振り返ります。

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