🐶

アバターでWeb会議に出たい

2022/06/25に公開

概要

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
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を定義したファイルを作ります

models.ts
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を準備する

https://www.live2d.com/download/cubism-sdk/download-web/
ここからダウンロードしてください

下準備

SDK内のFrameworkフォルダをsrc配下のもののみに置き換えてください

before
- Framework
  - LICENSE.md
  - その他諸々
  - src
    - physics
    - その他諸々
after
- Framework
  - physics
  - その他諸々

フォルダをアプリに移動

アプリケーションのsrc配下にlibフォルダを作成し、lib配下に先程ダウンロードしたSDKのCoreと下準備したFrameworkを移動してください

- src
  - lib
    - Framework
    - Core
  - App.tsx
  - その他諸々

他に必要なファイルを用意

https://astie.dog/blog/posts/mevdzj-4dsm
こちらのブログに記載されているgithubのソースコードから力を借りさせていただきます

この2つのファイルをダウンロードして、src/lib配下に配置します

- src
  - lib
    - Framework
    - Core
    - Live2dSDK.ts
    - CubismModel.ts
  - App.tsx
  - その他諸々

index.htmlを編集

SDKのCore内のlive2dcubismcore.min.jsをpublic配下に移動し、index.htmlに以下を追記する

index.html
 <script src="./live2dcubismcore.min.js"></script>

注意

SDKのtsファイルに型エラーが出る場合はstrict:falseにしたほうがよいかもです

renderer.tsを用意

こちらも 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
App.tsx
  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も用意してあげましょう

App.tsx
// 中略
      <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配下)

params.ts
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
renderer.ts
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
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を使ってモデルを作れば自前のアバターで会議に出ることも可能です

(↓↓↓お友達のアイコンをお借りしました)

参考記事

https://zenn.dev/sdkfz181tiger/articles/df3a8bd1c3ef25
https://qiita.com/kimamula/items/9338a83cf0dd7775b154
https://astie.dog/blog/posts/mevdzj-4dsm
https://amg-solution.jp/blog/26833

Discussion