見出し画像

MotionPNGTuber を React に移植+透過動画対応した話




ポケモンバトルAIアシスタント「バトルちゃん」では、
これまで Live2D を使ったキャラクター表示を行っていました。

ただし使用していたのはフリーモデルで、
オリジナルキャラクター化ができないという制約がありました。

では自分で Live2D モデルを用意するかというと、

  • Live2D のモデリング依頼にはそれなりの費用がかかる

  • 修正や調整にも時間が必要

  • ちょっとした検証や改善を気軽に回しづらい

という問題があります。

「もっと軽量に」
「もっと素早く」
「自分でコントロールできる形で」

口パク付きのキャラクター表示を実装できないか。
そう考えて方法を探していたときに出会ったのが MotionPNGTuber でした。


MotionPNGTuber は革命的だった

MotionPNGTuber はろてじんさんが開発したPNGTuberをアニメーションで動かすツールです。

  • ループ動画

  • 口のスプライト(開 / 閉)

  • 音声振幅

を組み合わせることで、Live2D風の口パク表現を実現する Python 製ツールです。

仕組みとしてはかなりシンプルですが、発想がとにかく強い。

これを最初に見たとき、
**「これ、バトルちゃんに転用できるのでは?」**と直感しました。


しかし、そのままでは使えなかった

課題1: バトルちゃんのGUIは React.js

MotionPNGTuber は Python 製で、
処理の中心は OpenCV / PyTorch / NumPy。

一方でバトルちゃんの配信・操作用GUIは React.js + Web

PythonのコードはReactでは動かせないので、移植しないといけないという前提がありました。


課題2: 透過動画が必須だった

バトルちゃんは ゲーム画面にオーバーレイ表示されます。

そのため、

  • 背景透過

  • キャラクターのみ表示

が絶対条件。

しかしこれが、今回一番ハマったポイントでした。


MotionPNGTuber の仕組み(整理)

まずは中身を理解するところから。

  1. ループ動画から口座標を検出

    • anime-face-detector

    • 結果は `mouth_track.npz`

  2. 口領域をインペイントで消去

  3. 音声振幅に応じて

    • 口スプライト(開 / 閉)を

    • 射影変換で合成

つまり、

「口の動き情報 + ループ動画 + 口スプライト」

さえあれば再現できる、という構造でした。


React 移植の方針

結論から言うと、React.js 化自体はそこまで大変ではなかったです。

やったことはシンプルで、

  • Python 側

    • 口座標トラッキングを 静的アセットとして事前生成

    • `npz → JSON` に変換

  • React 側

    • TypeScript + Canvas 2D で合成

    • スプライト or 動画を描画

    • 口の開閉度を制御

必要なコードを書き足すだけで移植することができました。

透過動画との死闘

正直に言うと、ここが一番の沼でした。

バトルちゃんはゲーム画面にオーバーレイ表示するため、
どうしても背景透過にしたかったんです

試行1: 黒背景 WebM からアルファ抽出

黒背景 WebM → アルファ抽出→ ❌ 失敗
輪郭線や影など、黒に近い色まで透明化されてしまいました。

試行2: アルファソース分離

白背景動画 → 透過: WebM(アルファ用)→ ❌ 失敗
WebM のアルファ抽出が不安定で、
フレームごとにマスク品質が揺れる問題が発生しました。

ここで 元動画(白背景)-> 透過処理 -> 口消去 というプロセスを諦め、
元動画(白背景)-> 口消去-> 透過処理 と順番を変更しました。

試行3: 白背景 → 白を透明化

白背景動画→ 白色を透明化→ ❌ 失敗
背景だけでなく、

  • 服のハイライト

  • 瞳の白

  • 細かい装飾

まで一緒に消えてしまい、実用に耐えませんでした。

最終解: rembg(AI 背景除去)

最終的に辿り着いた解決策は、
AI に背景だけを判断させる 方法でした。

元動画(白背景)→ 口消去処理→ rembg による背景除去

この方法では、

  • 服や瞳、眉毛などは保持

  • 背景のみを高精度に除去

  • 手動調整ほぼ不要

という結果が得られ、
ようやく実用レベルの透過スプライトを生成できました。

口消し範囲の調整も重要だった

自動検出の quad が 155×155px と大きすぎて、

  • 顔下部

  • 顎ライン

まで破壊される問題が発生。

最終的には 用途に合わせて quad を縮小して解決しました。

全機能を移植できたわけではない

正直に言うと、

  • Python 版の全機能

  • 高度な自動補正

まで 完全再現はしていません

ただし、「バトルちゃんとして使う範囲」に限れば、何の問題もありませんでした

まとめ

苦戦しましたが、最終的には満足のいく形で実装ができました。

  • WebでVTuber的表現をしたい人

  • Live2Dが重すぎると感じている人

の参考になれば嬉しいです。


このnoteでは、バトルちゃんの開発過程や、開発を通じて学んだことを共有していきます。

参考になったら、スキ・シェアしてもらえると励みになります!

バトルちゃんのYouTube → https://www.youtube.com/@battlechan-ai

おまけ

PythonのコードとTypeScriptのコードを置いておきます。

Python(npz => JSON変換)

#!/usr/bin/env python3
"""
convert_npz_to_json.py

MotionPNGTuberのmouth_track.npz / mouth_track_calibrated.npzを
フロントエンド用のJSONに変換する
"""

import argparse
import json
import numpy as np
from pathlib import Path


def convert_npz_to_json(npz_path: str, output_path: str | None = None) -> dict:
    """
    npzファイルをJSONに変換

    Args:
        npz_path: 入力npzファイルパス
        output_path: 出力JSONパス(Noneなら同じディレクトリに.json拡張子で保存)

    Returns:
        変換後のdict
    """
    npz = np.load(npz_path)

    # 基本データ
    data = {
        "fps": float(npz["fps"]),
        "width": int(npz["w"]),
        "height": int(npz["h"]),
        "frameCount": len(npz["quad"]),

        # 各フレームの口座標 (4点の四角形)
        # quad[i] = [[x0,y0], [x1,y1], [x2,y2], [x3,y3]]
        "quads": npz["quad"].tolist(),

        # フレームの有効フラグ
        "valid": npz["valid"].astype(int).tolist(),

        # 検出信頼度
        "confidence": npz["confidence"].tolist(),
    }

    # オプショナルフィールド
    if "ref_sprite_w" in npz:
        data["refSpriteWidth"] = int(npz["ref_sprite_w"])
    if "ref_sprite_h" in npz:
        data["refSpriteHeight"] = int(npz["ref_sprite_h"])
    if "pad" in npz:
        data["pad"] = float(npz["pad"])

    # キャリブレーション済みの場合の追加フィールド
    if "offset" in npz:
        data["calibration"] = {
            "offset": npz["offset"].tolist(),
            "scale": float(npz["scale"]) if "scale" in npz else 1.0,
            "rotationDeg": float(npz["rotation_deg"]) if "rotation_deg" in npz else 0.0,
        }

    # 出力パス決定
    if output_path is None:
        output_path = str(Path(npz_path).with_suffix(".json"))

    # JSON出力(コンパクト形式)
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(data, f, separators=(",", ":"))

    print(f"Converted: {npz_path} -> {output_path}")
    print(f"  Frames: {data['frameCount']}")
    print(f"  FPS: {data['fps']}")
    print(f"  Size: {data['width']}x{data['height']}")
    print(f"  File size: {Path(output_path).stat().st_size / 1024:.1f} KB")

    return data


def main():
    parser = argparse.ArgumentParser(
        description="Convert MotionPNGTuber npz to JSON for frontend"
    )
    parser.add_argument("input", help="Input npz file path")
    parser.add_argument("-o", "--output", help="Output JSON file path (optional)")

    args = parser.parse_args()
    convert_npz_to_json(args.input, args.output)


if __name__ == "__main__":
    main()


TypeScript(スプライトシートの制御)

/**
 * MotionAvatar.tsx
 *
 * MotionPNGTuber風の口パク合成コンポーネント
 * - スプライトシートまたは透過ループ動画からフレームを取得
 * - フレームに同期して口スプライトを射影変換で合成
 * - 振幅に応じて口の開き具合を制御
 */

import { useRef, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react';

// --- 型定義 ---

/** mouth_track.json の型 */
export interface MouthTrackData {
  fps: number;
  width: number;
  height: number;
  frameCount: number;
  /** quads[frameIndex] = [[x0,y0], [x1,y1], [x2,y2], [x3,y3]] */
  quads: [number, number][][];
  valid: number[];
  confidence: number[];
  refSpriteWidth?: number;
  refSpriteHeight?: number;
  pad?: number;
  calibration?: {
    offset: [number, number];
    scale: number;
    rotationDeg: number;
  };
}

/** スプライトシートのメタデータ */
export interface SpriteSheetMeta {
  frameCount: number;
  frameWidth: number;
  frameHeight: number;
  cols: number;
  rows: number;
  fps: number;
}

/** 外部から呼び出せるハンドル */
export interface MotionAvatarHandle {
  setMouthOpenY: (value: number) => void;
}

interface MotionAvatarProps {
  /** 口消し済み透過動画のURL (videoモード用) */
  videoSrc?: string;
  /** スプライトシート画像のURL (spriteモード用) */
  spriteSheetSrc?: string;
  /** スプライトシートのメタデータ */
  spriteSheetMeta?: SpriteSheetMeta;
  /** 口の位置データJSON */
  trackData: MouthTrackData;
  /** 口スプライト画像(開) */
  mouthOpenSrc: string;
  /** 口スプライト画像(閉) */
  mouthClosedSrc: string;
  /** 表示幅 */
  width?: number;
  /** 表示高さ */
  height?: number;
  /** 口の開閉しきい値 (0-1) */
  threshold?: number;
  /** 背景色(デバッグ用) */
  backgroundColor?: string;
}

/**
 * 2D射影変換行列を計算
 * src: 元画像の4点 (左上、右上、右下、左下)
 * dst: 変換先の4点
 */
function computePerspectiveTransform(
  src: [number, number][],
  dst: [number, number][],
): number[] | null {
  // 簡易実装: アフィン変換で近似(3点使用)
  // 完全な射影変換はより複雑な計算が必要
  const [s0, s1, , s3] = src;
  const [d0, d1, , d3] = dst;

  // 3点からアフィン変換行列を計算
  const sx = s1[0] - s0[0];
  const sy = s1[1] - s0[1];
  const tx = s3[0] - s0[0];
  const ty = s3[1] - s0[1];

  const dx = d1[0] - d0[0];
  const dy = d1[1] - d0[1];
  const ex = d3[0] - d0[0];
  const ey = d3[1] - d0[1];

  const det = sx * ty - sy * tx;
  if (Math.abs(det) < 1e-6) return null;

  const a = (dx * ty - dy * tx) / det;
  const b = (sx * dy - sy * dx) / det;
  const c = (ex * ty - ey * tx) / det;
  const d = (sx * ey - sy * ex) / det;

  return [a, b, c, d, d0[0] - a * s0[0] - c * s0[1], d0[1] - b * s0[0] - d * s0[1]];
}

const MotionAvatar = forwardRef<MotionAvatarHandle, MotionAvatarProps>(
  (
    {
      videoSrc,
      spriteSheetSrc,
      spriteSheetMeta,
      trackData,
      mouthOpenSrc,
      mouthClosedSrc,
      width = 400,
      height = 600,
      threshold = 0.3,
      backgroundColor,
    },
    ref,
  ) => {
    const videoRef = useRef<HTMLVideoElement>(null);
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const spriteSheetRef = useRef<HTMLImageElement | null>(null);
    const mouthOpenRef = useRef<HTMLImageElement | null>(null);
    const mouthClosedRef = useRef<HTMLImageElement | null>(null);
    const animationRef = useRef<number>(0);
    const startTimeRef = useRef<number>(0);

    const mouthValueRef = useRef(0);

    // スプライトシートモードかどうか
    const useSpriteSheet = Boolean(spriteSheetSrc && spriteSheetMeta);

    // 外部APIを公開
    useImperativeHandle(ref, () => ({
      setMouthOpenY: (value: number) => {
        mouthValueRef.current = Math.max(0, Math.min(1, value));
      },
    }));

    // 画像をロード
    useEffect(() => {
      const loadImage = (src: string): Promise<HTMLImageElement> => {
        return new Promise((resolve, reject) => {
          const img = new Image();
          img.onload = () => resolve(img);
          img.onerror = () => reject(new Error(`Failed to load: ${src}`));
          img.src = src;
        });
      };

      // 口スプライトをロード
      Promise.all([loadImage(mouthOpenSrc), loadImage(mouthClosedSrc)])
        .then(([open, closed]) => {
          mouthOpenRef.current = open;
          mouthClosedRef.current = closed;
        })
        .catch((e) => console.error('Mouth sprite load error:', e));

      // スプライトシートをロード
      if (spriteSheetSrc) {
        loadImage(spriteSheetSrc)
          .then((img) => {
            spriteSheetRef.current = img;
          })
          .catch((e) => console.error('Sprite sheet load error:', e));
      }
    }, [mouthOpenSrc, mouthClosedSrc, spriteSheetSrc]);

    // スケール計算
    const scaleX = width / trackData.width;
    const scaleY = height / trackData.height;

    // フレーム描画
    const render = useCallback(() => {
      const canvas = canvasRef.current;
      const ctx = canvas?.getContext('2d');

      if (!canvas || !ctx) {
        animationRef.current = requestAnimationFrame(render);
        return;
      }

      // 現在のフレーム番号を計算
      let frameIndex: number;

      if (useSpriteSheet && spriteSheetMeta) {
        // スプライトシートモード: 経過時間からフレームを計算
        if (startTimeRef.current === 0) {
          startTimeRef.current = performance.now();
        }
        const elapsed = (performance.now() - startTimeRef.current) / 1000;
        frameIndex = Math.floor(elapsed * spriteSheetMeta.fps) % spriteSheetMeta.frameCount;
      } else {
        // 動画モード
        const video = videoRef.current;
        if (!video || video.paused || video.ended) {
          animationRef.current = requestAnimationFrame(render);
          return;
        }
        frameIndex = Math.floor(video.currentTime * trackData.fps) % trackData.frameCount;
      }

      // キャンバスクリア
      ctx.clearRect(0, 0, width, height);

      // フレームを描画
      if (useSpriteSheet && spriteSheetRef.current && spriteSheetMeta) {
        // スプライトシートから切り出して描画
        const col = frameIndex % spriteSheetMeta.cols;
        const row = Math.floor(frameIndex / spriteSheetMeta.cols);
        const sx = col * spriteSheetMeta.frameWidth;
        const sy = row * spriteSheetMeta.frameHeight;

        ctx.drawImage(
          spriteSheetRef.current,
          sx,
          sy,
          spriteSheetMeta.frameWidth,
          spriteSheetMeta.frameHeight,
          0,
          0,
          width,
          height,
        );
      } else if (videoRef.current) {
        // 動画フレームを描画
        ctx.drawImage(videoRef.current, 0, 0, width, height);
      }

      // 口スプライトを描画
      const quad = trackData.quads[frameIndex];
      const isValid = trackData.valid[frameIndex] === 1;
      const mouthImg =
        mouthValueRef.current > threshold ? mouthOpenRef.current : mouthClosedRef.current;

      if (isValid && quad && mouthImg) {
        // スケール適用した座標
        const scaledQuad: [number, number][] = quad.map(([x, y]) => [x * scaleX, y * scaleY]) as [
          number,
          number,
        ][];

        // 口スプライトの元座標 (左上、右上、右下、左下)
        const spriteW = mouthImg.width;
        const spriteH = mouthImg.height;
        const srcQuad: [number, number][] = [
          [0, 0],
          [spriteW, 0],
          [spriteW, spriteH],
          [0, spriteH],
        ];

        // 変換行列を計算
        const transform = computePerspectiveTransform(srcQuad, scaledQuad);

        if (transform) {
          ctx.save();
          ctx.setTransform(
            transform[0],
            transform[1],
            transform[2],
            transform[3],
            transform[4],
            transform[5],
          );
          ctx.drawImage(mouthImg, 0, 0);
          ctx.restore();
        }
      }

      animationRef.current = requestAnimationFrame(render);
    }, [trackData, width, height, scaleX, scaleY, threshold, useSpriteSheet, spriteSheetMeta]);

    // アニメーションループ開始
    useEffect(() => {
      startTimeRef.current = 0; // リセット
      animationRef.current = requestAnimationFrame(render);
      return () => cancelAnimationFrame(animationRef.current);
    }, [render]);

    return (
      <div style={{ position: 'relative', width, height, background: backgroundColor }}>
        {/* 動画モード用の非表示要素 */}
        {!useSpriteSheet && videoSrc && (
          <video
            ref={videoRef}
            src={videoSrc}
            loop
            muted
            autoPlay
            playsInline
            style={{ display: 'none' }}
          />
        )}
        {/* 合成結果を表示するキャンバス */}
        <canvas ref={canvasRef} width={width} height={height} style={{ width, height }} />
      </div>
    );
  },
);

MotionAvatar.displayName = 'MotionAvatar';

export default MotionAvatar;


いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
買うたび 抽選 ※条件・上限あり \note クリエイター感謝祭ポイントバックキャンペーン/最大全額もどってくる! 12.1 月〜1.14 水 まで
MotionPNGTuber を React に移植+透過動画対応した話|ash
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1