在瀏覽器實作虛擬會議室


這一年因為武漢肺炎的關係,使用網路會議室開會的頻率變得越來越高,而現在幾個主流的服務都有提供虛擬會議室的功能,簡單來說就是無需綠幕的去背,藉此避免在家工作時背景亂糟糟的。

最近公司要我嘗試實作這功能,以下簡單紀錄一下做法與結果。

使用到的技術

可以理解到,此需求最核心的問題是如何從每一禎的視訊畫面中找出人在哪裡。
一開始去找了一些影像處理的涵式庫,例如 OpenCV 之類的,後來發現我找的方向錯了,真正需要的東西是 TensorFlow.js

TensorFlow 是個開源的機器學習工具,TensorFlow.js 就是他的 JavaScript 版,可以跑在 Node.js 或者直接跑在瀏覽器上。最棒的是它有提供幾個已經預先訓練好的模型可以使用,其中就包含辨識人體的機器學習模型 BodyPix

於是大概的思維如下:

  1. 使用瀏覽器的 API 擷取視訊畫面
  2. 把畫面丟給 TensorFlow 分離出人與背景的像素
  3. 在 html canvas 把背景與切割好的人物像素合成
  4. 把 canvas 當成視訊串流進行輸出
  5. 把串流接上 WebRTC

1. 取得視訊畫面

引入涵式庫

<head>
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@2.0.0/dist/tf.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/body-pix@2.1"></script>
</head>

準備需要的元素

<body>
  <video id="video" src="" hidden></video>
  <canvas id="canvas"></canvas>
</body>

然後撰寫 js

const videoElement = document.getElementById("video");
const canvas = document.getElementById("canvas");
let width = 640;
let height = 480;

(async () => {
  let stream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: false,
  });

  videoElement.onplaying = () => {
    let settings = stream.getVideoTracks()[0].getSettings();
    width = settings.width;
    height = settings.height;

    canvas.width = width;
    canvas.height = height;
    init();
  };
  videoElement.srcObject = stream;
  videoElement.play();
})();

大致上就是取得視訊畫面,然後丟到 html video 上面播放,撥放後執行 init() 開始後續工作。
記得要存好拿到影片的長寬資訊,繪圖的時候會需要。

2. 執行 BodyPix

async function init() {
  const modelOption = {
    architecture: "MobileNetV1",
    outputStride: 16,
    multiplier: 0.75,
    quantBytes: 2,
  };
  let net = await bodyPix.load(modelOption);
  let image = await loadImage("my-background.jpg");
  setInterval(() => draw(net, image), 100); //ms
}
  1. 初始化模型,這邊設定的方式請參考官方說明文件,選擇的結果會影響判斷的準確度與需要的硬體性能。
  2. 從檔案讀取需要使用的背景照片。找個漂亮的風景圖或會議室照片之類的,也有公司有提供免費的圖給大家玩,如果你的老闆不介意看到你在鬼滅之刃神隱少女的場景開會的話。
  3. 定時重複執行繪圖

3. 合成畫面

const foregroundColor = { r: 0, g: 0, b: 0, a: 255 };
const backgroundColor = { r: 0, g: 0, b: 0, a: 0 };
const segmentOption = {
  flipHorizontal: true,
  internalResolution: "medium",
  segmentationThreshold: 0.5,
};
async function draw(net, backgroundImage) {
  let segmentation = await net.segmentPerson(videoElement, segmentOption);
  let maskData = bodyPix.toMask(segmentation, foregroundColor, backgroundColor);
  let ctx = canvas.getContext("2d");

  ctx.globalCompositeOperation = "destination-over";
  ctx.putImageData(maskData, 0, 0);

  ctx.globalCompositeOperation = "source-in";
  ctx.drawImage(videoElement, 0, 0, width, height);

  ctx.globalCompositeOperation = "destination-atop";
  ctx.drawImage(backgroundImage, 0, 0, width, height);
}
  1. 設定前景、背景色、與辨識參數(參數一樣參考官方文件設定)
  2. 執行主程式 bodyPix.toMask() 取得遮罩
  3. 將遮罩與視訊畫面、背景畫面疊圖

理論上至此已經可以在瀏覽器上測試,並於螢幕上看到運算結果。

4.取得串流

最後執行 canvas.captureStream() 即可將 canvas 轉換成 MediaStream ,然後就去串接自己的 WebRTC 服務吧。

成果如何?

不好意思露臉就沒放測試的圖了。
辨識的結果我覺得還算準確,只要你不是穿跟背景顏色一樣的衣服,基本上可以精確抓出頭、手、身體。
但問題是滿吃效能的,測試過程中我的瀏覽器當掉過好幾次,沒當機也是記憶體滿載,不確定是瀏覽器性能的原罪,還是我程式撰寫還需要改善。

總之目前的成果我覺得不到可以正式上線的程度,勉強堪用而已。
有點好奇那使用 Node.js 來跑會不會比較好?之後有空再來嘗試吧。

Hi 喜歡這篇文章的話 可以按個讚或請我喝杯咖啡
Buy me a coffeeBuy me a coffee