MotionPNGTuber を React に移植+透過動画対応した話
ろてじんさんのMotionPNGTuberをReactに埋め込んでみました!
— ash ポケモン対戦AI (@shumpeiasahi) December 28, 2025
npzをJSON変換して、React側でCanvasに描画するだけ!
簡単にLive2Dっぽくオリジナルのイラストを動かすことができて感動です!! https://t.co/cKUrokxtMx pic.twitter.com/NLy6nQrADp
ポケモンバトル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 の仕組み(整理)
まずは中身を理解するところから。
ループ動画から口座標を検出
anime-face-detector
結果は `mouth_track.npz`
口領域をインペイントで消去
音声振幅に応じて
口スプライト(開 / 閉)を
射影変換で合成
つまり、
「口の動き情報 + ループ動画 + 口スプライト」
さえあれば再現できる、という構造でした。
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;

コメント