アバターでWeb会議に出たい
概要
FaceRigとか有料のソフトを使わずに自前でLive2dのモデルを動かすアプリケーションを作って、Web会議に出たいというお話です
おことわり
パフォーマンスの考慮ができてないのでPCのスペックによっては動かない可能性もあります
今日のゴール
MeetにLive2dのアバターで参加
使用するツール・ライブラリ
- React (なんでもいい)
- MediaPipeのFacemesh
- Live2dCubismのJavascript版SDK
- kalidokitトラッキングのデータからアバターのパラメータに変換してくれたりするスグレモノ?(よくわかっていない)
- OBS仮想カメラを起動するために使用
前提
Reactの適当なアプリケーションを作成しておいてください
MediaPipeのFeceMeshを動かす
Webカメラの映像から顔をトラッキングするためにMediaPipeのFacemeshを使います
yarn add @mediapipe/drawing_utils @mediapipe/camera_utils @mediapipe/face_mesh
@mediapipe/drawing_utils
はトラッキングしたデータを描画するために使います↓↓↓
App.tsxを以下のように編集します
App.tsx
import { useCallback, useEffect, useRef } from "react";
import "./App.css";
import {
FaceMesh,
FACEMESH_TESSELATION,
NormalizedLandmarkList,
Results as FaceResult,
} from "@mediapipe/face_mesh";
import { Camera } from "@mediapipe/camera_utils";
import { drawConnectors, drawLandmarks } from "@mediapipe/drawing_utils";
export const App = () => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawResults = useCallback((points: NormalizedLandmarkList) => {
const videoElement = videoRef.current;
const canvasElement = canvasRef.current;
if (!canvasElement || !videoElement || !points) return;
canvasElement.width = videoElement.videoWidth ?? 500;
canvasElement.height = videoElement.videoHeight ?? 300;
const canvasCtx = canvasElement.getContext("2d");
if (!canvasCtx) return;
canvasCtx.save();
canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
// 用意したcanvasにトラッキングしたデータを表示
drawConnectors(canvasCtx, points, FACEMESH_TESSELATION, {
color: "#C0C0C070",
lineWidth: 1,
});
if (points && points.length === 478) {
drawLandmarks(canvasCtx, [points[468], points[468 + 5]], {
color: "#ffe603",
lineWidth: 2,
});
}
}, []);
// facemeshから結果が取れたときのコールバック関数
const onResult = useCallback(
(results: FaceResult) => {
drawResults(results.multiFaceLandmarks[0]);
},
[drawResults]
);
useEffect(() => {
const videoElement = videoRef.current;
if (videoElement) {
const facemesh = new FaceMesh({
locateFile: (file) => {
return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`;
},
});
// facemeshのオプション(詳細はドキュメントを)
facemesh.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5,
});
// facemeshの結果が取得できたときのコールバックを設定
facemesh.onResults(onResult);
const camera = new Camera(videoElement, {
onFrame: async () => {
// frameごとにWebカメラの映像をfacemeshのAPIに投げる
await facemesh.send({ image: videoElement });
},
width: 1280,
height: 720,
});
camera.start();
}
}, [onResult]);
return (
<div className="App">
<video
style={{
position: "absolute",
top: 0,
left: 0,
// カメラの映像が違和感がないように反転
transform: "ScaleX(-1)",
}}
ref={videoRef}
/>
<canvas
style={{
top: 0,
left: 0,
position: "absolute",
transform: "ScaleX(-1)",
}}
ref={canvasRef}
/>
</div>
);
};
これで顔のトラッキングとデータの描画ができます(カメラの映像にはblurをかけています)
Live2dモデルの表示
モデルを用意する
ここからダウンロードしてください
(今回はHarutoのモデルを使用します)
フォルダが以下のような構成になるように調整し、publicフォルダに配置します
- Haruto
- haruto.2048
- motion
- haruto.cdli3.json
- haruto.moc3
- haruto.model3.json
- haruto.physics3.json
後々拡張しやすいようにmodelを定義したファイルを作ります
interface Models {
[key: string]: Model;
}
export interface Model {
moc3: string;
model3: string;
physics3: string;
textures: string[];
}
export const models: Models = {
haruto: {
moc3: `${process.env.PUBLIC_URL}/Haruto/haruto.moc3`,
model3: `${process.env.PUBLIC_URL}/Haruto/haruto.model3.json`,
physics3: `${process.env.PUBLIC_URL}/Haruto/haruto.physics3.json`,
textures: [
`${process.env.PUBLIC_URL}/Haruto/haruto.2048/texture_00.png`,
],
}
};
Live2dのSDKを準備する
ここからダウンロードしてください
下準備
SDK内のFrameworkフォルダをsrc配下のもののみに置き換えてください
before
- Framework
- LICENSE.md
- その他諸々
- src
- physics
- その他諸々
after
- Framework
- physics
- その他諸々
フォルダをアプリに移動
アプリケーションのsrc配下にlib
フォルダを作成し、lib
配下に先程ダウンロードしたSDKのCore
と下準備したFramework
を移動してください
- src
- lib
- Framework
- Core
- App.tsx
- その他諸々
他に必要なファイルを用意
こちらのブログに記載されているgithubのソースコードから力を借りさせていただきます
この2つのファイルをダウンロードして、src/lib
配下に配置します
- src
- lib
- Framework
- Core
- Live2dSDK.ts
- CubismModel.ts
- App.tsx
- その他諸々
index.htmlを編集
SDKのCore
内のlive2dcubismcore.min.js
をpublic配下に移動し、index.html
に以下を追記する
<script src="./live2dcubismcore.min.js"></script>
注意
SDKのtsファイルに型エラーが出る場合はstrict:false
にしたほうがよいかもです
renderer.tsを用意
こちらも renderer.tsを拝見し、ちょっと改良します。
renderer.ts
import AppCubismUserModel from "./lib/CubismModel";
import {
CubismFramework,
CubismMatrix44,
CubismModelSettingJson,
ICubismModelSetting,
} from "./lib/Live2dSDK";
export interface AvatarArrayBuffers {
moc3: ArrayBuffer;
textures: Blob[];
physics: ArrayBuffer;
}
interface Live2dRendererOption {
autoBlink: boolean;
x: number;
y: number;
scale: number;
}
const DEFAULT_OPTION: Live2dRendererOption = {
autoBlink: true,
x: 0,
y: 0,
scale: 1,
};
export async function live2dRender(
canvas: HTMLCanvasElement,
_model: ArrayBuffer,
buffers: AvatarArrayBuffers,
options: Partial<Live2dRendererOption> = {}
) {
const gl = canvas.getContext("webgl");
if (gl === null) throw new Error("WebGL未対応のブラウザです。");
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
const option = Object.assign({}, DEFAULT_OPTION, options);
const frameBuffer: WebGLFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING);
CubismFramework.startUp();
CubismFramework.initialize();
const defaultPosition = Object.assign(
{
x: 0,
y: 0,
z: 0.2,
},
{
x: option.x,
y: option.y,
z: option.scale,
}
);
const modelSetting = new CubismModelSettingJson(
_model,
_model.byteLength
) as ICubismModelSetting;
const {
moc3: moc3ArrayBuffer,
textures,
physics: physics3ArrayBuffer,
} = buffers;
const model = new AppCubismUserModel();
model.loadModel(moc3ArrayBuffer);
model.createRenderer();
let i = 0;
for (let buffer of textures) {
const texture = await createTexture(buffer, gl);
model.getRenderer().bindTexture(i, texture);
i++;
}
model.getRenderer().setIsPremultipliedAlpha(true);
model.getRenderer().startUp(gl);
for (
let i = 0, len = modelSetting.getEyeBlinkParameterCount();
i < len;
i++
) {
model.addEyeBlinkParameterId(modelSetting.getEyeBlinkParameterId(i));
}
for (let i = 0, len = modelSetting.getLipSyncParameterCount(); i < len; i++) {
model.addLipSyncParameterId(modelSetting.getLipSyncParameterId(i));
}
model.loadPhysics(physics3ArrayBuffer, physics3ArrayBuffer.byteLength);
const projectionMatrix = new CubismMatrix44();
const resizeModel = () => {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const modelMatrix = model.getModelMatrix();
modelMatrix.bottom(0);
modelMatrix.centerY(-1);
modelMatrix.translateY(-1);
projectionMatrix.loadIdentity();
const canvasRatio = canvas.height / canvas.width;
if (1 < canvasRatio) {
modelMatrix.scale(1, canvas.width / canvas.height);
} else {
modelMatrix.scale(canvas.height / canvas.width, 1);
}
modelMatrix.translateRelative(defaultPosition.x, defaultPosition.y);
projectionMatrix.multiplyByMatrix(modelMatrix);
const scale = 2;
projectionMatrix.scaleRelative(scale, scale);
projectionMatrix.translateY(-0.6);
model.getRenderer().setMvpMatrix(projectionMatrix);
};
resizeModel();
const viewport: number[] = [0, 0, canvas.width, canvas.height];
viewport[2] = canvas.width;
viewport[3] = canvas.height;
model.getRenderer().setRenderState(frameBuffer, viewport);
model.getRenderer().drawModel();
window.onresize = () => {
resizeModel();
};
return model;
}
async function createTexture(
blob: Blob,
gl: WebGLRenderingContext
): Promise<WebGLTexture> {
return new Promise((resolve: (texture: WebGLTexture) => void) => {
const url = URL.createObjectURL(blob);
const img: HTMLImageElement = new Image();
img.onload = () => {
const tex: WebGLTexture = gl.createTexture() as WebGLTexture;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
URL.revokeObjectURL(url);
return resolve(tex);
};
img.addEventListener("error", () => {
console.error(`image load error`);
});
img.src = url;
});
}
App.tsxを編集
App.tsxにrendererを利用する記述を追記します
(モデルのロードにaxiosを使っています)
yarn add axios
const [mod, setMod] = useState<AppCubismUserModel | null>(null);
const avatarCanvasRef = useRef<HTMLCanvasElement>(null);
const load = useCallback(async () => {
if (canvasRef.current!) {
try {
const [model, moc3, physics, ...textures] = await Promise.all([
axios
.get<ArrayBuffer>(live2dModel.model3, {
responseType: "arraybuffer",
})
.then((res) => res.data),
axios
.get(live2dModel.moc3, { responseType: "arraybuffer" })
.then((res) => res.data),
axios
.get(live2dModel.physics3, { responseType: "arraybuffer" })
.then((res) => res.data),
...live2dModel.textures.map(async (texture) => {
const res = await axios.get(texture, { responseType: "blob" });
return res.data;
}),
]);
const mod = await live2dRender(
avatarCanvasRef.current!,
model,
{
moc3,
physics,
textures,
},
{
autoBlink: true,
x: 0,
y: 0,
scale: 4,
}
);
setMod(mod);
} catch (e) {
console.error(e);
}
}
}, []);
useEffect(() => {
load();
}, [load]);
アバター用のcanvasも用意してあげましょう
// 中略
<canvas
ref={avatarCanvasRef}
width={1200}
height={720}
style={{
top: 0,
left: 0,
position: "absolute",
}}
/>
これでLive2dのモデルが表示されるようになります(カメラは非表示にしています)
Live2dモデルを動かす
kalidokitをinstallします
yarn add kalidokit
params.tsを作成
Live2dモデルのパラメーター指定のIDがモデルによって異なるため、以下を用意する(src配下)
export const params = {
angleX: {
0: "PARAM_ANGLE_X",
1: "ParamAngleX",
},
angleY: {
0: "PARAM_ANGLE_Y",
1: "ParamAngleY",
},
angleZ: {
0: "PARAM_ANGLE_Z",
1: "ParamAngleZ",
},
eyeBallX: {
0: "PARAM_EYE_BALL_X",
1: "ParamEyeBallX",
},
eyeBallY: {
0: "PARAM_EYE_BALL_Y",
1: "ParamEyeBallY",
},
bodyAngleX: {
0: "PARAM_BODY_ANGLE_X",
1: "ParamBodyAngleX",
},
bodyAngleY: {
0: "PARAM_BODY_ANGLE_Y",
1: "ParamBodyAngleY",
},
bodyAngleZ: {
0: "PARAM_BODY_ANGLE_Z",
1: "ParamBodyAngleZ",
},
eyeLOpen: {
0: "PARAM_EYE_L_OPEN",
1: "ParamEyeLOpen",
},
eyeROpen: {
0: "PARAM_EYE_R_OPEN",
1: "ParamEyeROpen",
},
mouthOpen: {
0: "PARAM_MOUTH_OPEN_Y",
1: "ParamMouthOpenY",
},
mouthForm: {
0: "PARAM_MOUTH_FORM",
1: "ParamMouthForm",
},
};
renderer.tsを編集
以下を追記します
renderer.tsx
import { Face, TFace, Vector } from "kalidokit";
import { params } from "./params";
// 中略
export const draw = (
canvas: HTMLCanvasElement,
lastUpdateTime: number,
model: AppCubismUserModel,
faceRig: TFace | undefined,
type: 0 = 0
) => {
const gl = canvas.getContext("webgl");
if (gl === null) throw new Error("WebGL未対応のブラウザです。");
const frameBuffer: WebGLFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING);
const viewport: number[] = [0, 0, canvas.width, canvas.height];
const time = Date.now();
const deltaTimeSecond = (time - lastUpdateTime) / 1000;
const _model = model.getModel();
const idManager = CubismFramework.getIdManager();
const lerpAmount = 0.7;
if (faceRig) {
_model.setParameterValueById(
idManager.getId(params.angleX[type]),
Vector.lerp(
faceRig.head.degrees.y,
_model.getParameterValueById(idManager.getId(params.angleX[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.angleY[type]),
Vector.lerp(
faceRig.head.degrees.x,
_model.getParameterValueById(idManager.getId(params.angleY[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.angleZ[type]),
Vector.lerp(
faceRig.head.degrees.z,
_model.getParameterValueById(idManager.getId(params.angleZ[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.eyeBallX[type]),
Vector.lerp(
faceRig.pupil.x,
_model.getParameterValueById(idManager.getId(params.eyeBallX[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.eyeBallY[type]),
Vector.lerp(
faceRig.pupil.y,
_model.getParameterValueById(idManager.getId(params.eyeBallY[type])),
lerpAmount
)
);
const dampener = 0.3;
_model.setParameterValueById(
idManager.getId(params.bodyAngleX[type]),
Vector.lerp(
faceRig.head.degrees.y * dampener,
_model.getParameterValueById(idManager.getId(params.bodyAngleX[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.bodyAngleY[type]),
Vector.lerp(
faceRig.head.degrees.x * dampener,
_model.getParameterValueById(idManager.getId(params.bodyAngleY[type])),
lerpAmount
)
);
_model.setParameterValueById(
idManager.getId(params.bodyAngleZ[type]),
Vector.lerp(
faceRig.head.degrees.z * dampener,
_model.getParameterValueById(idManager.getId(params.bodyAngleZ[type])),
lerpAmount
)
);
let stabilizedEyes = Face.stabilizeBlink(
{
l: Vector.lerp(
faceRig.eye.l,
_model.getParameterValueById(idManager.getId(params.eyeLOpen[type])),
0.7
),
r: Vector.lerp(
faceRig.eye.r,
_model.getParameterValueById(idManager.getId(params.eyeROpen[type])),
0.7
),
},
faceRig.head.y
);
_model.setParameterValueById(
idManager.getId(params.eyeLOpen[type]),
stabilizedEyes.l
);
_model.setParameterValueById(
idManager.getId(params.eyeROpen[type]),
stabilizedEyes.r
);
_model.setParameterValueById(
idManager.getId(params.mouthOpen[type]),
Vector.lerp(
faceRig.mouth.y,
_model.getParameterValueById(idManager.getId(params.mouthOpen[type])),
0.3
)
);
_model.setParameterValueById(
idManager.getId(params.mouthForm[type]),
0.3 +
Vector.lerp(
faceRig.mouth.x,
_model.getParameterValueById(idManager.getId(params.mouthForm[type])),
0.3
)
);
}
_model.saveParameters();
// // 頂点の更新
model.update(deltaTimeSecond);
if (model.isMotionFinished) {
const idx = Math.floor(Math.random() * model.motionNames.length);
const name = model.motionNames[idx];
model.startMotionByName(name);
}
viewport[2] = canvas.width;
viewport[3] = canvas.height;
model.getRenderer().setRenderState(frameBuffer, viewport);
// モデルの描画
model.getRenderer().drawModel();
lastUpdateTime = time;
};
App.tsxを編集
App.tsx
const animateLive2DModel = useCallback(
(points: NormalizedLandmarkList) => {
const videoElement = videoRef.current;
if (!mod || !points) return;
let riggedFace = Face.solve(points, {
runtime: "mediapipe",
video: videoElement,
});
let lastUpdateTime = Date.now();
draw(avatarCanvasRef.current!, lastUpdateTime, mod, riggedFace, 0);
},
[mod]
);
// onResult内でanimateLive2DModelを呼び出すように修正
const onResult = useCallback(
(results: FaceResult) => {
drawResults(results.multiFaceLandmarks[0]);
animateLive2DModel(results.multiFaceLandmarks[0]);
},
[animateLive2DModel, drawResults]
);
順調に行けばこんな感じでアバターが動くと思います(カメラは非表示にしています)
ここまで来ればあとはOBSの仮想カメラを起動して、Meetで選択するだけです
OBSの仮想カメラを準備
ここからOBSをダウンロードします
ダウンロードしてOBSを起動したら、左下のSourcesの+からWindow Capture
を選択してOKを押します
Window選択画面で、先程作成したアプリケーションのWindowを選択するとOBSの画面上にアバターが表示されます
あとは仮想カメラを開始するとMeetのカメラ設定にOBSの仮想カメラが出てくるので選択するとアバターでビデオ会議に出ることができます
OBSの仮想カメラが認識されない場合はChromeを再起動したりしてみてください
アプリケーションの背景を赤にしてクロマキーに設定したり、背景をよしなにしてあげれば完成です
おまけ
レイヤー分けしたイラスト(psdファイル)からLive2d Editorを使ってモデルを作れば自前のアバターで会議に出ることも可能です
(↓↓↓お友達のアイコンをお借りしました)
参考記事
Discussion