はじめに
これはInfocom Advent Calendar 2019 15日目の記事です。
過去に社内でWebRTCを使ったビデオ会議のデモをした際に、「在宅勤務で使うときに、部屋の様子(背景)を隠して通話できないか?」というリクエストを受けたことがありました。プライバシー保護言えば顔や人物を隠すことを想定していましたが、周囲の方を隠したいとの要望があることにちょっと驚きました。
今回はそれができるように、次の要素を組み合わせたサンプルを作ったので紹介します。
- tensorflow.js の人体検出モデル tfjs-models/body-pix (Apache 2.0 ライセンス)
- WebAudioを使ったピッチシフト Pitch shifter (MIT ライセンス)
- WebRTC シグナリングサービス Ayame Lite(株式会社時雨堂)
- そのSDK ayame-web-sdk(Apache 2.0 ライセンス)
tensorflow.js
tensorflow.js は、Googleが開発している機械学習用のフレームワークtensorflowを、JavaScriptで実装したものです。ブラウザ上やNode.jsで利用することができます。
当初は学習済みモデルを使った推論に特化していましたが、現在は学習もできるようになっています。すでに学習済みモデルが多数公開されているのが嬉しいところで、今回利用するtfjs-models/body-pix も、その一つです。
Body-pixによる人体検出
tensorflow.js の body-pix の使い方は、公式サイトの説明を見るのが詳しいです。と言ってしまうと終わりなので、以下に説明します。
JSの読み込み
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@1.2"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/body-pix@2.0"></script>
モデルのロード
モデルのロードは非同期なので、今回はawaitを使って完了を待ちます。
let bodyPixNet = null;
async function loadModel() {
const net = await bodyPix.load(/** optional arguments, see below **/);
bodyPixNet = net;
}
loadModel();
画像から人体を検出
人体の検出はセグメンテーションと呼ばれます。こちらも非同期処理です。imgタグと、先ほど読み込んだモデル(net)を渡して、プロミスを返しています。
function segmentPerson(img, net) {
/**
* One of (see documentation below):
* - net.segmentPerson
* - net.segmentPersonParts
* - net.segmentMultiPerson
* - net.segmentMultiPersonParts
* See documentation below for details on each method.
*/
const option = {
flipHorizontal: false,
internalResolution: 'medium',
segmentationThreshold: 0.7,
maxDetections: 4,
scoreThreshold: 0.5,
nmsRadius: 20,
minKeypointScore: 0.3,
refineSteps: 10
};
return net.segmentPerson(img, option);
}
オプションは色々指定できますが、基本的には公式のサンプルの値を使い、人体の最大数 maxDetections だけ変更しました。
画像をマスク
先ほど取得したセグメンテーションを使って、画像をマスクします。マスクを作って、それを使ってキャンバスに描画する、という2ステップになります。
// セグテーションを実行
const segment = await segmentPerson(img, bodyPixNet)
maskCanvas(canvas, img, segment);
// 画像をマスクする
function maskCanvas(canvas, img, segmentation) {
// マスクを作成
const fgColor = { r: 255, g: 0, b: 0, a: 128 }; // 人物は赤、半透明
const bgColor = { r: 0, g: 0, b: 0, a: 0 }; // 背景は透明(色は黒だが無関係)
const colorMask = bodyPix.toMask(segmentation, fgColor, bgColor);
// マスクを使って描画
const opacity = 1.0;
const flipHorizontal = false;
const maskBlurAmount = 0;
bodyPix.drawMask(
canvas, img, colorMask, opacity, maskBlurAmount,
flipHorizontal);
}
ここまでのデモ(画像をマスク)
ここまでのデモがこちらです。
- ソースコード(GitHub) image_mask.html
- デモページ(GitHub Pages) image_mask.html
人体の移った画像ファイルを選択して、数秒~10秒待つと、元の画像と人体が赤く半透明にマスクされた画像が表示されます。
動画から連続して人体検出
動画から継続して人体を検出&マスクし続けるには、次の2つが必要です。
- (a) 動画のコマから人体を検出して、マスクを作成
- (b) 動画のコマ毎に、マスクを使って描画
(b)はなるべくフレームレートを上げて、動画がコマ落ちさせたくないところです。なので、window.requestAnimationFrame()を使って、マシンのパフォーマンスが許す限り描画します。
一方(a)の人体検出(セグメンテーション)は非同期なので、(b)とは切り離してsetTimeout()で断続的に実行するします。連携は原始的にグローバル変数を使っちゃいました(ショボい)
(a) 動画のコマから人体検出
動画から人体を検出し、マスクを作成する処理はこちらです。今回マスクの種類は3種類対応しました。
- person ... 人物をマスクする
- room ... 背景(周囲の部屋の様子)をマスクする
- none ... マスク無し
function updateSegment() {
const segmeteUpdateTime = 10; // ms
const option = {
// 省略
};
// マスク無しなら、マスクをクリアして終了
if (maskType === 'none') {
bodyPixMaks = null;
if (contineuAnimation) {
// 次の人体セグメンテーションの実行を予約する
segmentTimerId = setTimeout(updateSegment, segmeteUpdateTime);
}
return;
}
// ビデオから、人体をセグメンテーション
bodyPixNet.segmentPerson(localVideo, option)
.then(segmentation => {
if (maskType === 'room') { // 背景をマスクする場合
const fgColor = { r: 0, g: 0, b: 0, a: 0 }; // 人体部分は透明
const bgColor = { r: 127, g: 127, b: 127, a: 255 }; // 周囲はグレイ、不透明
const personPartImage = bodyPix.toMask(segmentation, fgColor, bgColor);
bodyPixMaks = personPartImage; // マスクをグローバル変数に保持
}
else if (maskType === 'person') { // 背景をマスクする場合
const fgColor = { r: 127, g: 127, b: 127, a: 255 }; // 人体部分はグレイ、不透明
const bgColor = { r: 0, g: 0, b: 0, a: 0 }; // 周囲は透明
const roomPartImage = bodyPix.toMask(segmentation, fgColor, bgColor);
bodyPixMaks = roomPartImage; // マスクをグローバル変数に保持
}
else {
bodyPixMaks = null;
}
if (contineuAnimation) {
// 次の人体セグメンテーションの実行を予約する
segmentTimerId = setTimeout(updateSegment, segmeteUpdateTime);
}
})
.catch(err => {
console.error('segmentPerson ERROR:', err);
})
}
(b) マスクを使って動画のコマを描画
先ほど用意したマスクをつかって、videoからCanvasに描画しています。これを requestAnimationFrame() を使って、連続的に実行しています。今回はmaskBlurAmount を使って、マスクの境界にボケ効果を入れています。
function updateCanvas() {
drawCanvas(localVideo);
if (contineuAnimation) {
animationId = window.requestAnimationFrame(updateCanvas);
}
}
function drawCanvas(srcElement) {
const opacity = 1.0;
const flipHorizontal = false;
//const maskBlurAmount = 0;
const maskBlurAmount = 3; // マスクの周囲にボケ効果を入れる
// Draw the mask image on top of the original image onto a canvas.
// The colored part image will be drawn semi-transparent, with an opacity of
// 0.7, allowing for the original image to be visible under.
bodyPix.drawMask(
canvas, srcElement, bodyPixMaks, opacity, maskBlurAmount,
flipHorizontal
);
}
カメラ映像の取得
順番が前後しましたが、元にするカメラ映像の取得には、getUserMedia()を使います。
async function startVideo() {
//const mediaConstraints = {video: true, audio: true};
//const mediaConstraints = {video: true, audio: false};
// 今回は、640x480、オーディオなしで取得
const mediaConstraints = { video: { width: 640, height: 480 }, audio: false };
disableElement('start_video_button');
localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints).catch(err => {
console.error('media ERROR:', err);
enableElement('start_video_button');
return;
});
localVideo.srcObject = localStream;
await localVideo.play().catch(err => console.error('local play ERROR:', err));
localVideo.volume = 0;
// ... 省略 ...
}
ここまでのデモ(動画をマスク)
ここまでのデモがこちらです。Webカメラが必要です。
- ソースコード(GitHub) video_mask.html
- デモページ(GitHub Pages) video_mask.html
[Start Video]ボタンをクリックすると、カメラへのアクセスの許可を求められます。許可を与えて数秒~10秒程度末と、元のカメラ映像と、マスクされた映像が表示されます。
ラジオボタンでマスクの種類を動的に切り替えることができます。
つづく
力尽きたので、今回はここまでにします。近日中に音声変換と通信のところを書く予定です(願望)