🤣

【ネタ】笑ってはいけない Web 会議を作ってみる。デモアプリ編

に公開

※今回はネタ投稿です。
ガキ使にインスパイアを受けて、「笑ってはいけない Web 会議」ができるアプリを作ろうと思います。
今回はデモアプリ編ということで、『Web カメラの映像から笑顔を検出して、「ババーン!〇〇 OUT !」の演出をする』ところまで作ってみました。

成果物

リポジトリ URL: https://github.com/t-tonyo-maru/pub_web_warattehaikenai-web-mtg
デモ URL: https://t-tonyo-maru.github.io/pub_web_warattehaikenai-web-mtg/

デモ URL を閲覧するうえでの注意点

  • 「ババーン!」の例の音が鳴ります。
    音量を低めに設定していますが、ご注意ください。
  • PC で閲覧してください。
    モバイルは未検証です。モバイルでも動作するとは思いますが、かなり処理が重いと思います。

動作確認手順

  1. デモ URL にアクセスします。
  2. Web カメラの利用許可が求められますので、許可をしてください。
    • Web カメラで得た映像の収集・保存などはしませんので、ご安心ください。
      あくまで表情を解析するために、デモ URL 内で利用するだけです。
  3. Web カメラに映った人物が笑うと「ババーン!」の効果音と「〇〇 OUT」の文字が展開されます。
  4. 画面右上のコントロールパネルから、デモアプリの設定を変更できます。
    • 笑顔の判定ライン: 本デモアプリは「検出された顔の表情が喜び・幸せ(happy)である確率」が閾値を超えることで「笑っている」とみなします。その閾値を変更できます。
      あくまで確率による判定であるため、コントロールパネル上で 1 にすると「笑っている」と判定されなくなります。
    • SE のボリューム: 「ババーン!」の効果音のボリュームを変更できます。
    • SE を再生する: 「ババーン!」の効果音を再生するかどうかを切り替えられます。
    • 罰ゲーム対象者: 「〇〇 OUT」の「〇〇」に展開する文字を変更できます。
      (ココリコ田中さんが好きなので、デフォルトは田中です。)

参考サイト・素材サイト

本デモアプリは face-api.js (Github / npm)を利用しています。
face-api.js の使い方は、【うわっ...私の表情、硬すぎ...?】face-api.jsで顔検出して感情と年齢を判定するを参考にしています。素晴らしい解説記事です。

また、「ババーン!」の効果音は DOVA-SYNDROME を利用しました。

解説

本デモアプリの処理はすべて src/main.ts に記述しています。
Web カメラとの許可取得と face-api.js との連携のみ解説します。

src/main.ts
// face-api.js モデルを読み込みます … ①
Promise.all([
  faceapi.nets.tinyFaceDetector.loadFromUri(`${GITHUB_PAGES_PATH}/weights`),
  faceapi.nets.faceLandmark68Net.loadFromUri(`${GITHUB_PAGES_PATH}/weights`),
  faceapi.nets.faceRecognitionNet.loadFromUri(`${GITHUB_PAGES_PATH}/weights`),
  faceapi.nets.faceExpressionNet.loadFromUri(`${GITHUB_PAGES_PATH}/weights`),
  faceapi.nets.ageGenderNet.loadFromUri(`${GITHUB_PAGES_PATH}/weights`)
])
  .then(async () => {
    // モデルの読み込み後に、Webカメラの許可をユーザーに求めます … ②
    return await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: SCREEN.w },
        height: { ideal: SCREEN.h }
      }
    });
  })
  .then((stream) => {
    // video 要素の srcObject に stream をセットします … ③
    videoEl.srcObject = stream;
  })
  .catch((error) => {
    console.error(error);
  });

// Web カメラが許可され、video に再生されたときに発火する関数 … ④
videoEl.addEventListener('play', () => {
  const canvas = faceapi.createCanvasFromMedia(videoEl);
  canvas.id = 'canvas';
  canvas.width = SCREEN.w;
  canvas.height = SCREEN.h;
  app.append(canvas);
  const context = canvas.getContext('2d');

  faceapi.matchDimensions(canvas, {
    width: SCREEN.w,
    height: SCREEN.h
  });

  const detectFaces = async () => {
    const detections = await faceapi
      .detectAllFaces(videoEl, new faceapi.TinyFaceDetectorOptions())
      .withFaceLandmarks()
      .withFaceExpressions()
      .withAgeAndGender();
    const resizedDetections = faceapi.resizeResults(detections, {
      width: SCREEN.w,
      height: SCREEN.h
    });

    if (context) context.clearRect(0, 0, canvas.width, canvas.height);
    faceapi.draw.drawDetections(canvas, resizedDetections);
    faceapi.draw.drawFaceLandmarks(canvas, resizedDetections);
    faceapi.draw.drawFaceExpressions(canvas, resizedDetections);

    if (isOut && context) {
      // 「〇〇 OUT」を描画します
      const text = `${outUserName} OUT`;
      context.font = 'bold 48px Arial';
      context.textAlign = 'center';
      context.strokeStyle = 'white';
      context.lineWidth = 4;
      context.strokeText(text, canvas.width / 2, canvas.height - 40);
      context.fillStyle = 'red';
      context.fillText(text, canvas.width / 2, canvas.height - 40);
      // text align をリセット
      context.textAlign = 'left';
    }

    for (const detection of resizedDetections) {
      // face-api のボックスの描画します
      const drawBox = new faceapi.draw.DrawBox(detection.detection.box, {});
      drawBox.draw(canvas);

      // detection.expressions.happy から「ユーザーが笑顔か」を判定します … ⑤
      // ババーン!は連続発火させずに、アイドリングタイム分だけ処理を間引きます
      if (detection.expressions.happy > smileBorderLine) {
        if (!(lastOutTime < IDLING_TIME)) {
          isOut = true;
          lastOutTime = IDLING_TIME;
          seEl.play();
        }
      }
      if (isOut) {
        lastOutTime -= DECREMENT_TIME;
      }
      if (lastOutTime <= 0) {
        isOut = false;
        lastOutTime = IDLING_TIME;
      }
    }

    // setTimeout で再帰的に detectFaces 関数を実行します
    setTimeout(() => detectFaces(), DECREMENT_TIME);
  };

  setTimeout(() => detectFaces(), DECREMENT_TIME);
});
  • ①: face-api.js のモデルを読み込みます。
    • 今回は Vite プロジェクトで組んでいます。face-api.js リポジトリの weights に格納されたファイルをダウンロードして、public 配下に格納しました。
  • ②: モデルの読み込み後に、Webカメラの許可をユーザーに求めます。
    • navigator.mediaDevices.getUserMedia() で、Webカメラの許可をユーザーに求めます。
    • then() チェーンになっているので、モデルの読み込みが完了後に発火します。
  • ③: video 要素の srcObject に stream をセットします。
    • これで video 要素に Web カメラの映像が展開されます。
  • ④: Web カメラが許可され、video に再生されたときに発火する関数を用意します。
  • ⑤: detection.expressions.happy から「ユーザーが笑顔か」を判定します。
    • Web カメラの映像に登場する顔の表情を resizedDetections から detection として取り出します。resizedDetections 配列の各要素が、検出された顔の情報を持つオブジェクトです。
      そこから、さらに detection.expressions.happy = 検出された顔の表情が喜び・幸せ(happy)である確率を取り出します。
    • あとは、その値が閾値を超えていれば、「ババーン!」の音が鳴らされます。
    • 「ババーン!」の音が連続で鳴らされるのを防ぐために、アイドリングタイム(5秒)を設定しています。

まとめ

さて、デモアプリができましたので、次は実際に Web 会議ができるようしていこうと思います。
Wails とか Electron あたりを使って、デスクトップアプリを作れると良さそうですかね?

次回はいつになるか分かりませんが、やる気と実装する時間ができましたら、続きをやってみたいと思います〜。

Discussion