7
Help us understand the problem. What are the problem?

posted at

updated at

Organization

【リモートワーク】Web会議で自分の顔を画像に変えるのをmediaPipe+p5jsでやってみた【顔見せNG】

こんにちは。Webフロントエンジニアのsuginokoです。
暑くて溶けそうですが、アイスでも食べて頑張りましょう。

調査背景

弊社 ではフルリモートで仕事をするようになっており、
当然会議するときはWeb会議になるのですが、弊社はシャイ(?)が多くて常に顔を出さずに会議することが主になっています。
そんな中で、会社で「水曜日は顔出しDayにしよう!」となり、水曜日は顔出し推奨デーになりました。

この顔出しDayが苦手です(みなさんごめんなさい土下座します)。なぜなら自分の顔が自分で確認できてしまって嫌になるからです。
しかし私も自分の立場が立場で(無駄に歳食う年代になってきたし…)水曜日は顔を出すようにしてますが、リモートのスッピンに慣れた乙女にとって顔出しdayは深刻な問題です・・・ 私もエンジニアの端くれ、ならば技術で解決してやろうと思ったのです。
美男美女の皆さんには申し訳ないですが、コツコツと作りました。

目的

  • 会議で顔を隠したい 隠れればそれでいい。可能ならどんな角度でも画像に置き換わってほしい
  • 化粧もめんどくさいので隠したい 化粧だるい
  • 使いたい気分もあるので、好きな画像で顔を隠したい
  • WebRTC(ここでは軽くAgora.ioに触れてます)でも使えたらいいな
  • 弊社で使っているサービスに取り入れることができるんじゃないかと思って作ってもみてます(強調!!!)

環境

  • React v18.2.0
  • TypeScript
  • webpack v4
  • react-p5 v1.3.30
  • @mediapipe/face_mesh v0.4.1633559619 (途中@mediapipe/camera_utilsあたりも使ってます)
  • agora-rtc-sdk-ng (おまけ)

※訳あって所々古め。

顔認証をどうするか

今回はGoogleが作っているMediaPipeのFace Mesh を使って顔認証を行いました。
こちらを選んだのは、

  • Googleが開発していること
  • 色々な顔認証ライブラリのdemoを試しましたが、顔を結構な角度傾けても画像に置き換えてくれて目的を果たしてくれている(他のライブラリでは一定の角度で素顔が見えてしまう)
  • 今回試したのはFace Meshのみですが、MediaPipeの公式を見ると顔認証以外にも色々対応してそうなのでFace Meshをやれば他の応用が利きそうだから
  • demo見る感じ、認証の精度も高めな気がする

という理由で選定しました。
※ただし、公式サイトにあるように

Alpha disclaimer
MediaPipe is currently in alpha at v0.7. We may be still making breaking API changes and expect to get to stable APIs by v1.0.

とあるので、正式版ではありません。なので参考程度にみてもらえればと。

顔を検知する

顔を検知させます。Face Meshのサンプルコードがあるのでこれを参考にReactに置き換えます。CSSは適当です。

// 顔の検出まで
import {
  FaceMesh,
  Results as FaceResult,
  NormalizedLandmarkList,
  FACEMESH_TESSELATION
} from '@mediapipe/face_mesh'
import React, { useCallback, useEffect, useRef } from 'react'
import { Camera } from '@mediapipe/camera_utils'
import { drawConnectors, drawLandmarks } from "@mediapipe/drawing_utils";
// import "./index.scss";


const MediaPipePage: React.FC = () => {
  const cameraRef = useRef<HTMLVideoElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)

  const getContext = (): CanvasRenderingContext2D => {
    const canvas: any = canvasRef.current;
    return canvas.getContext('2d');
  }


  const drawCanvas = useCallback((results: FaceResult) => {
    const points: NormalizedLandmarkList = results.multiFaceLandmarks[0]
    const videoElement = cameraRef.current
    const canvasElement = canvasRef.current

    if (!canvasElement || !videoElement || !points) return
    canvasElement.width = videoElement.videoWidth ?? 500
    canvasElement.height = videoElement.videoHeight ?? 300
    const canvasCtx = getContext()
    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,
      })
    }
  }, [])

  const onResults = useCallback((results: FaceResult) => {
    drawCanvas(results)
  }, [drawCanvas])

  const camera = async (faceMesh: any, videoElement: HTMLVideoElement) => {
    return new Camera(videoElement, {
      onFrame: async () => {
        await faceMesh.send({ image: videoElement })
      },
      width: 1280,
      height: 720
    })
  }

  // facemeshのsetup
  const setUp = async () => {
    const videoElement = cameraRef.current
    if (videoElement && videoElement) {
      const faceMesh = new FaceMesh({
        locateFile: file => {
          return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
        }
      })
      console.log(faceMesh)
  
      faceMesh.setOptions({
        maxNumFaces: 1,
        refineLandmarks: true,
        minDetectionConfidence: 0.5,
        minTrackingConfidence: 0.5,
      })
  
      faceMesh.onResults(onResults)

      const c = await camera(faceMesh, videoElement)
      await c.start()
    }
  }

  useEffect(() => {
    setUp()
  }, [onResults])

  return (
    <div className="mediaPipePage">
      <video ref={ cameraRef } style={{
        position: "absolute",
        marginLeft: "auto",
        marginRight: "auto",
        left: 0,
        right: 0,
        textAlign: "center",
        width: 1280,
        height: 720,
      }} />
      {/* canvas */}
      <canvas ref={ canvasRef } style={{
        position: "absolute",
        marginLeft: "auto",
        marginRight: "auto",
        left: 0,
        right: 0,
        textAlign: "center",
        width: 1280,
        height: 720,
      }} width={1280} height={720} />
    </div>
  )
}

export default MediaPipePage

カメラに映した映像をCanvasに置き換えて表示
faceMeshで顔を認証して顔のキーポイント(ここでの表現はLandmarkというらしいので、以下Landmarkと表現します)を描画する
という動きです。
カメラはサンプルコードにあるように @mediapipe/camera_utils を使って映します。

そうすると
image.png

このような感じで顔認証をすることができました。割と細かく認証されています。
目の部分だけ黄色になってます。

隠したい顔のLandmarkを探す

顔のどこを隠すのか、Landmarkを探します。
顔のLandmarkを1つ1つ探すのが手間だったのでこちらこちら で何となく把握したので、これらをもとに隠したいLandmarkをメモします。
image.png

取ってみるとこんな感じです。常に口が空いているのが気になります。

10,323、152、93(顔周りのlandmark)

青い点が上記のlandmarkになっています。

顔認証情報から画像に置き換える

今回は参考サイトをもとにp5.jsを使っていますがなんでもいいと思います。(参考サイト様ありがとうございます。)
p5.jsの部分は以下のように書きました。
※TypeScriptで書いていてanyばかりで恥ずかしいのですが。

import React, { useEffect, useRef, useState } from 'react'
import p5Types from "p5";
import Sketch from "react-p5";

interface ComponentProps {
	keyPoints: any,
	videoElement: HTMLVideoElement
}

const WIDTH = 1280
const HEIGHT = 720

const P5Canvas: React.FC<ComponentProps> = (props: ComponentProps) => {
  const videoImage = useRef<any>(null)
	const [ image, setImage ] = useState<any>(null)

	const imagePreload = (p5: p5Types) => {
		setImage(p5.loadImage('./avatar20180327200631.jpg'))
	}

	const setup = (p5: p5Types, canvasParentRef: Element) => {
		imagePreload(p5)
		p5.createCanvas(WIDTH, HEIGHT).parent(canvasParentRef);
        videoImage.current = p5.createGraphics(640,360)
	};

	const draw = (p5: p5Types) => {
		if (!videoImage.current) return
		p5.clear()
		videoImage.current.drawingContext.drawImage(
			props.videoElement,
			0,
			0,
			videoImage.current.width,
			videoImage.current.height
		)
		p5.push()
		p5.translate(p5.width, 0)
		p5.scale(-1, 1)

		const displayWidth = p5.width
		const displayHeight = (p5.width * videoImage.current.height) / videoImage.current.width

		p5.image(videoImage.current, 0, 0, displayWidth, displayHeight)
		p5.pop()

		if (props.keyPoints?.length > 0) {
			const facePoints = {
				up: props.keyPoints[0][10],
				right: props.keyPoints[0][323],
				down: props.keyPoints[0][152],
				left: props.keyPoints[0][93],
				center: props.keyPoints[0][1]
			}
			drawFace(p5, facePoints, displayWidth, displayHeight)
		}

	}

	const drawFace = (p5: p5Types, position: any, displayWidth: number, displayHeight: number) => {
		p5.push()
		p5.imageMode(p5.CENTER)
		p5.tint(255, 255) // 透明度
		
		const x_width = Math.sqrt(Math.pow(position.right.x - position.left.x, 2) + Math.pow(position.right.y - position.left.y, 2));
		const y_width = Math.sqrt(Math.pow(position.up.x - position.down.x, 2) + Math.pow(position.up.y - position.down.y, 2));

		if (image != null) {
			console.log('image', image, position.center.x)
			p5.image(
				image,
				(1 - position.center.x) * displayWidth,
				position.center.y * displayHeight,
				image.width * x_width * 5,
				image.height * y_width * 3
			);
			p5.pop();
		}
	}

	return (
		<>
			<Sketch setup={ setup } draw={ draw } />
		</>
	)
};

export default P5Canvas

これで以下のように顔が画像に置き換わります
output.gif

この状態だけでもOBS使えばmeetやzoomでも使うことができますね。
もう少し進めます。

これだと画像1枚しか使えないから気分で画像を変えたい

画像を固定にしたくないので自分でアップロードした画像で顔を画像に置き換えたい気持ちがあったので、そこに対応しようと思います。

一部変更します。

const prevData = usePrevious(image) // react hook使って前の画像と同じかどうかチェック 

// stateで画像の保存とp5の方でも画像保存
const imagePreload = (p5: p5Types, img: any = null) => {
	setImage(img ? img : './avatar20180327200631.jpg')
	setp5Image(p5.loadImage(img ? img : './avatar20180327200631.jpg'))
}

// 好きな画像アップロード
const onUploadImage = (e: React.ChangeEvent<HTMLInputElement>) => {
	// ファイルがない
	if (!e.target.files) return
	if (e.target.files?.length === 0) return
	// 画像ではない場合return
	if (!e.target.files?.[0].type.match("image.*")) {
		return
	}
	const reader = new FileReader()

	reader.onload = (e) => {
		// setImage(null)
		setTimeout(() => {
			setImage(e.target?.result)
			console.log('e.target?.result', e.target?.result)
		}, 1000)
	}
	reader.readAsDataURL(e.target?.files[0])
}

const draw = (p5: p5Types) => {
	if (image == null || prevData != image) {
		imagePreload(p5, image)
	}
    // ...
}

そうすると
output3.gif
こうなります。
ちなみに、横縦大きすぎる画像をあげると
image.png
こうなります。サイズ調整したいところ…。ひとまずは小さすぎる画像さえアップロードしなければ顔が隠れますね。

ここまでくるとWebRTCに突っ込めるか調べたい

これは興味あったのでAgoraを使ってビデオ通話でも画像が送れるか調べました。
Agoraは月に10000分までは無料で使用できます。実験に10000分使わないのでアカウント登録して使ってみます。

Agoraのカメラを使うので、@mediapipe/camera_utils で使ってたカメラの実装をAgoraに置き換えて実装をします。
この辺は調べてもあんまり出てこなかったのでちょっと苦労を。。。

Agora実装

とりあえず配信者、視聴者と分けます。
配信者側が顔画像をアップロードできる形にして、配信者はAgora側に顔を画像に変化させている映像を配信できるかどうか
視聴者は配信者がアップロードした画像を映像を通して確認できるか、画像も変更されているか
を確認します。

ソースが長すぎるのでここでは部分的に紹介します。対応は以下のような感じ
基本はAgoraのDoc にあるように実装されています。
配信者の動きは以下です。

  1. 配信用のチャンネルを作り、指定したチャンネルに参加。
  2. 配信する前にローカル上でVideoタグを生成。このVideoタグを持ちつつ、faceMeshもロードさせる。
  3. VideoタグとfaceMeshの情報を取得できたら、faceMeshの情報を送り続けるためにrequestAnimationFrame使ってループさせる。(@mediapipe/camera_utilsだとonFrameで対応してくれていた箇所を自分で実装した感じ。)
  4. 1~3の情報をもってcanvasを生成(この時点でローカル上では自分の顔が画像に変換されている)
  5. canvasの情報をAgoraに送る必要があるのでAgoraのcreateCustomVideoTrackを使って突っ込む
  6. 映像を公開する

視聴者は上記の配信者のチャンネルに参加するだけで問題ありません。
配信者が公開した情報をsubscribeして、配信者側の配信情報を視聴者側でも見えるように実装しましょう。

本当に部分的にですが実装は以下です。Agora部分は長すぎるので割愛します。

// 配信者として
const hostJoinChannel = useCallback(() => {
    rtc?.initBroadcast(async () => { // 1 
      await rtc.playLocalVideo() // 2
      await facemeshLoad() // 2
      await rtc.publishFaceTracking() // 5,6
    })
}, [rtc, onResults])

// 視聴者として
const viewerJoinChannel = useCallback(() => {
    if (viewerRtc) viewerRtc.initViewer()
}, [viewerRtc])

const createFaceMesh = async () => {
  await faceTrackingLoop()
}

// 顔検出ループ
const faceTrackingLoop: () => void = async () => {
    if ( videoEle ) {
      await mesh.send({ image: videoEle })
      window.requestAnimationFrame(faceTrackingLoop)
    }
}

useEffect(() => {
    if (mesh && videoEle) {
      createFaceMesh() // 3
    }
}, [mesh, videoEle])

左側が配信者で、右側が視聴者です。
output4.gif
一瞬顔見えるのが気になりますが、視聴者側が配信者の映像が受け取れていることがわかります。
これでWebRTCでも顔を画像に変更することができましたし、好きな画像をアップロードしても視聴者側でも確認できたので配信はうまくいってそうです。

調査してみて、できそうだけどやってないこと

  • アップロードした画像の画像大きさ調整(アップロードした素の画像が反映されるだけ)
  • 今回は顔を隠しただけだけど、landmarkさえわかればいろんなところ隠せるから面白そう
  • 投影されている動画(Video)の画角が違うとcanvasに映すと画角が変わって場合によっては映像が横長になってしまったりするので調整が必要そう(これは多分調整できそうな気もする
  • 3dモデルも映すことができるそうなので、試したくはある。(とりあえずもう一つ試したい顔認証ライブラリのfaceApi試してからかな)

見えた課題

  • これもしかして、バーチャル背景とかと一緒に使ったら共存できるのか?どうなんだろう
  • canvasのサイズを固定にして実装したけど、レスポンシブな対応をしたときにどうなるかはわかってない。
  • 正式バージョンではないので、正式になっても動くか?
  • 表情検出が出来たらもっと楽しいと思うが、そこまではまだできなそう

参考サイト

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
7
Help us understand the problem. What are the problem?