Appels vidéo avec arrière-plan virtuel et outils open source

Maintenant que beaucoup d'entre nous sont mis en quarantaine en raison de COVID-19 , les appels vidéo sont devenus beaucoup plus fréquents qu'auparavant. En particulier, le service ZOOM est soudainement devenu très populaire. La fonction Zoom la plus intéressante est probablement la prise en charge de l' arrière-plan virtuel . Il permet aux utilisateurs de remplacer de manière interactive l'arrière-plan derrière eux par n'importe quelle image ou vidéo.



J'utilise Zoom au travail depuis longtemps, lors de réunions open source sur Kubernetes, généralement à partir d'un ordinateur portable d'entreprise. Maintenant, lorsque je travaille à domicile, je suis enclin à utiliser un ordinateur de bureau personnel plus puissant et plus pratique pour résoudre certaines de mes tâches open source.

Malheureusement, Zoom prend uniquement en charge une méthode de suppression d'arrière-plan connue sous le nom de « incrustation chroma » ou « écran vert ». Pour utiliser cette méthode, il est nécessaire que l'arrière-plan soit représenté par une couleur unie, idéalement verte, et soit uniformément éclairé.

Comme je n'ai pas d'écran vert, j'ai décidé de simplement implémenter mon propre système de suppression d'arrière-plan. Et cela, bien sûr, est bien mieux que de mettre de l'ordre dans l'appartement ou d'utiliser constamment un ordinateur portable de travail.

Il s'est avéré qu'en utilisant des composants open source prêts à l'emploi et en écrivant quelques lignes de votre propre code, vous pouvez obtenir des résultats très décents.

Lecture des données de la caméra


Commençons par le début et répondons à la question suivante: "Comment obtenir la vidéo d'une webcam que nous allons traiter?"

Comme j'utilise Linux sur mon ordinateur personnel (quand je ne joue pas à des jeux), j'ai décidé d'utiliser les liaisons Open CV Python , que je connais déjà. En plus des liaisons V4L2 pour lire les données d'une webcam, elles incluent des fonctions de traitement vidéo de base utiles.

La lecture d'un cadre depuis une webcam en python-opencv est très simple:

import cv2
cap = cv2.VideoCapture('/dev/video0')
success, frame = cap.read()

Pour améliorer les résultats lorsque je travaille avec ma caméra, j'ai appliqué les paramètres suivants avant d'en capturer la vidéo:

#    720p @ 60 FPS
height, width = 720, 1280
cap.set(cv2.CAP_PROP_FRAME_WIDTH ,width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT,height)
cap.set(cv2.CAP_PROP_FPS, 60)

On a le sentiment que la plupart des programmes de visioconférence limitent la vidéo à 720p @ 30 FPS ou moins. Mais nous, dans tous les cas, ne lisons pas toutes les images. Ces paramètres définissent la limite supérieure.

Mettez le mécanisme de capture d'image dans une boucle. Maintenant, nous avons accès au flux vidéo de la caméra!

while True:
    success, frame = cap.read()

Vous pouvez enregistrer le cadre à des fins de test comme suit:

cv2.imwrite("test.jpg", frame)

Après cela, nous pouvons nous assurer que la caméra fonctionne. Génial!


J'espère que tu n'es pas contre ma barbe

Détection d'arrière-plan


Maintenant que nous avons accès au flux vidéo, nous allons réfléchir à la façon de détecter l'arrière-plan en permettant de le remplacer en le trouvant. Mais c'est déjà une tâche assez difficile.

Bien qu'il y ait un sentiment que les créateurs de Zoom ne parlent jamais exactement de la façon dont le programme supprime l'arrière-plan, la façon dont le système se comporte me fait penser à ce qui aurait pu se passer des réseaux de neurones. C'est difficile à expliquer, mais les résultats ressemblent exactement à ça. En outre, j'ai trouvé un article sur la façon dont Microsoft Teams implémente le flou d'arrière-plan à l' aide d'un réseau neuronal convolutionnel .

En principe, la création de votre propre réseau de neurones n'est pas si difficile. Il existe de nombreux articles et articles scientifiques sur la segmentation d'images.. Il existe de nombreuses bibliothèques et outils open source. Mais nous avons besoin d'un ensemble de données très spécialisé pour obtenir de bons résultats.

En particulier, nous avons besoin de beaucoup d'images ressemblant à celles obtenues à partir d'une webcam, avec une image parfaite d'une personne au premier plan. Chaque pixel d'une telle image doit être marqué comme différent de l'arrière-plan.

La construction d'un tel ensemble de données en préparation de la formation d'un réseau neuronal peut ne pas nécessiter beaucoup d'efforts. Cela est dû au fait que l'équipe de chercheurs de Google a déjà fait tout son possible et a mis en open source un réseau neuronal pré-formé pour segmenter les gens. Ce réseau s'appelle BodyPix . Il fonctionne très bien!

BodyPix n'est désormais disponible que sous une forme adaptée à TensorFlow.js. Par conséquent, il est plus facile d'appliquer à l'aide de la bibliothèque body-pix-node .

Pour accélérer la sortie réseau (prévisions) dans le navigateur, il est préférable d'utiliser le backend WebGL , mais dans l'environnement Node.js , vous pouvez utiliser le backend Tensorflow GPU (notez que cela nécessitera une carte vidéo de NVIDIA , que j'ai).

Afin de simplifier la configuration du projet, nous utiliserons un petit environnement conteneurisé qui fournit le GPU TensorFlow et Node.js. Tout utiliser avec nvidia-docker- beaucoup plus facile que de collecter vous-même les dépendances nécessaires sur votre ordinateur. Pour ce faire, vous n'avez besoin que de Docker et des derniers pilotes graphiques sur votre ordinateur.

Voici le contenu du fichier bodypix/package.json:

{
    "name": "bodypix",
    "version": "0.0.1",
    "dependencies": {
        "@tensorflow-models/body-pix": "^2.0.5",
        "@tensorflow/tfjs-node-gpu": "^1.7.1"
    }
}

Voici le dossier bodypix/Dockerfile:

#  ,   TensorFlow GPU
FROM nvcr.io/nvidia/cuda:10.0-cudnn7-runtime-ubuntu18.04
#  node
RUN apt update && apt install -y curl make build-essential \
    && curl -sL https://deb.nodesource.com/setup_12.x | bash - \
    && apt-get -y install nodejs \
    && mkdir /.npm \
    && chmod 777 /.npm
# ,     
#   tfjs-node-gpu      GPU :(
ENV TF_FORCE_GPU_ALLOW_GROWTH=true
#  node-
WORKDIR /src
COPY package.json /src/
RUN npm install
#      
COPY app.js /src/
ENTRYPOINT node /src/app.js

Parlons maintenant de l'obtention de résultats. Mais je vous préviens tout de suite: je ne suis pas un expert Node.js! Ce n'est que le résultat de mes expériences du soir, alors soyez indulgents avec moi :-).

Le script simple suivant est occupé à traiter une image de masque binaire envoyée au serveur à l'aide d'une requête HTTP POST. Un masque est un tableau bidimensionnel de pixels. Les pixels représentés par des zéros sont l'arrière-plan.

Voici le code du fichier app.js:

const tf = require('@tensorflow/tfjs-node-gpu');
const bodyPix = require('@tensorflow-models/body-pix');
const http = require('http');
(async () => {
    const net = await bodyPix.load({
        architecture: 'MobileNetV1',
        outputStride: 16,
        multiplier: 0.75,
        quantBytes: 2,
    });
    const server = http.createServer();
    server.on('request', async (req, res) => {
        var chunks = [];
        req.on('data', (chunk) => {
            chunks.push(chunk);
        });
        req.on('end', async () => {
            const image = tf.node.decodeImage(Buffer.concat(chunks));
            segmentation = await net.segmentPerson(image, {
                flipHorizontal: false,
                internalResolution: 'medium',
                segmentationThreshold: 0.7,
            });
            res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
            res.write(Buffer.from(segmentation.data));
            res.end();
            tf.dispose(image);
        });
    });
    server.listen(9000);
})();

Pour convertir un cadre en masque, nous pouvons, dans un script Python, utiliser le paquet numpy et request :

def get_mask(frame, bodypix_url='http://localhost:9000'):
    _, data = cv2.imencode(".jpg", frame)
    r = requests.post(
        url=bodypix_url,
        data=data.tobytes(),
        headers={'Content-Type': 'application/octet-stream'})
    #     numpy-
    #     uint8[width * height]   0  1
    mask = np.frombuffer(r.content, dtype=np.uint8)
    mask = mask.reshape((frame.shape[0], frame.shape[1]))
    return mask

Le résultat est approximativement le suivant.


Masque

Pendant que je faisais tout cela, je suis tombé sur letweet suivant .


C'est certainement le meilleur fond pour les appels vidéo.

Maintenant que nous avons un masque pour séparer le premier plan de l'arrière-plan, remplacer l'arrière-plan par quelque chose d'autre sera très simple.

J'ai pris l'image d'arrière-plan de la branche de tweet et je l'ai coupée pour obtenir une image 16x9.


Image d'arrière-plan

Après cela, j'ai fait ce qui suit:

#    (     16:9)
replacement_bg_raw = cv2.imread("background.jpg")

#    ,       (width & height   )
width, height = 720, 1280
replacement_bg = cv2.resize(replacement_bg_raw, (width, height))

#     ,   
inv_mask = 1-mask
for c in range(frame.shape[2]):
    frame[:,:,c] = frame[:,:,c]*mask + replacement_bg[:,:,c]*inv_mask

C'est ce que j'ai obtenu après ça.


Le résultat du remplacement de l'arrière-plan.

Un tel masque n'est évidemment pas assez précis, la raison en est les compromis de performance que nous avons faits lors de la configuration de BodyPix. En général, alors que tout semble plus ou moins tolérant.

Mais, quand j'ai regardé ce contexte, une idée m'est venue.

Expériences intéressantes


Maintenant que nous avons compris comment masquer, nous allons demander comment améliorer le résultat.

La première étape évidente consiste à adoucir les bords du masque. Par exemple, cela peut être fait comme ceci:

def post_process_mask(mask):
    mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
    mask = cv2.erode(mask, np.ones((10,10), np.uint8) , iterations=1)
    return mask

Cela améliorera un peu la situation, mais il n'y a pas beaucoup de progrès. Et un simple remplacement est assez ennuyeux. Mais, puisque nous sommes arrivés à tout cela nous-mêmes, cela signifie que nous pouvons tout faire avec l'image, et pas seulement supprimer l'arrière-plan.

Étant donné que nous utilisons un arrière-plan virtuel de Star Wars, j'ai décidé de créer un effet hologramme afin de rendre l'image plus intéressante. De plus, cela vous permet de lisser le flou du masque.

Tout d'abord, mettez à jour le code de post-traitement:

def post_process_mask(mask):
    mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
    mask = cv2.blur(mask.astype(float), (30,30))
    return mask

Les bords sont maintenant flous. C'est bien, mais nous devons encore créer un effet hologramme.

Les hologrammes hollywoodiens ont généralement les propriétés suivantes:

  • Une image pâle ou monochrome - comme si elle était dessinée par un laser brillant.
  • Un effet qui rappelle les lignes de balayage ou quelque chose comme une grille - comme si l'image était affichée en plusieurs rayons.
  • «Effet fantôme» - comme si la projection était effectuée en couches ou comme si la distance correcte à laquelle elle devait être affichée ne serait pas maintenue lors de la création de la projection.

Tous ces effets peuvent être implémentés étape par étape.

Tout d'abord, pour colorer l'image dans une nuance de bleu, nous pouvons utiliser la méthode applyColorMap:

#     -  
holo = cv2.applyColorMap(frame, cv2.COLORMAP_WINTER)

Ensuite - ajoutez une ligne de balayage avec un effet rappelant de laisser en demi-teinte:

#    bandLength    10-30%,
#    bandGap.
bandLength, bandGap = 2, 3
for y in range(holo.shape[0]):
    if y % (bandLength+bandGap) < bandLength:
        holo[y,:,:] = holo[y,:,:] * np.random.uniform(0.1, 0.3)

Ensuite, nous implémentons «l'effet fantôme» en ajoutant des copies pondérées décalées de l'effet actuel à l'image:

# shift_img : https://stackoverflow.com/a/53140617
def shift_img(img, dx, dy):
    img = np.roll(img, dy, axis=0)
    img = np.roll(img, dx, axis=1)
    if dy>0:
        img[:dy, :] = 0
    elif dy<0:
        img[dy:, :] = 0
    if dx>0:
        img[:, :dx] = 0
    elif dx<0:
        img[:, dx:] = 0
    return img

#    : holo * 0.2 + shifted_holo * 0.8 + 0
holo2 = cv2.addWeighted(holo, 0.2, shift_img(holo1.copy(), 5, 5), 0.8, 0)
holo2 = cv2.addWeighted(holo2, 0.4, shift_img(holo1.copy(), -5, -5), 0.6, 0)

Et enfin, nous voulons conserver certaines des couleurs d'origine, nous combinons donc l'effet holographique avec le cadre d'origine, en faisant quelque chose de similaire à l'ajout de «l'effet fantôme»:

holo_done = cv2.addWeighted(img, 0.5, holo2, 0.6, 0)

Voici à quoi ressemble un cadre avec un effet hologramme:


Un cadre avec un effet hologramme

Ce cadre lui-même semble assez bon.

Essayons maintenant de le combiner avec l'arrière-plan.


Image superposée sur le fond.

Terminé! (Je promets - ce genre de vidéo sera plus intéressant).

Sortie vidéo


Et maintenant, je dois dire que nous avons manqué quelque chose ici. Le fait est que nous ne pouvons toujours pas utiliser tout cela pour passer des appels vidéo.

Afin de résoudre ce problème, nous utiliserons pyfakewebcam et v4l2loopback pour créer une webcam factice.

De plus, nous prévoyons de connecter cette caméra au Docker.

Créez d'abord un fichier de fakecam/requirements.txtdescription des dépendances:

numpy==1.18.2
opencv-python==4.2.0.32
requests==2.23.0
pyfakewebcam==0.1.0

Créez maintenant un fichier fakecam/Dockerfilepour l'application qui implémente les capacités d'une caméra factice:

FROM python:3-buster
#   pip
RUN pip install --upgrade pip
#   opencv
RUN apt-get update && \
    apt-get install -y \
      `# opencv requirements` \
      libsm6 libxext6 libxrender-dev \
      `# opencv video opening requirements` \
      libv4l-dev
#    requirements.txt
WORKDIR /src
COPY requirements.txt /src/
RUN pip install --no-cache-dir -r /src/requirements.txt
#   
COPY background.jpg /data/
#     (     )
COPY fake.py /src/
ENTRYPOINT python -u fake.py

Maintenant, à partir de la ligne de commande, installez v4l2loopback:

sudo apt install v4l2loopback-dkms

Configurer une caméra factice:

sudo modprobe -r v4l2loopback
sudo modprobe v4l2loopback devices=1 video_nr=20 card_label="v4l2loopback" exclusive_caps=1

Pour garantir la fonctionnalité de certaines applications (Chrome, Zoom), nous avons besoin d'un paramètre exclusive_caps. La marque card_labelest définie uniquement pour garantir la commodité du choix d'un appareil photo dans les applications. L'indication du numéro video_nr=20conduit à la création de l'appareil /dev/video20si le numéro correspondant n'est pas occupé, et il est peu probable qu'il soit occupé.

Nous allons maintenant modifier le script pour créer une caméra factice:

# ,  ,   ,   ,  width  height
fake = pyfakewebcam.FakeWebcam('/dev/video20', width, height)

Il convient de noter que pyfakewebcam attend des images avec des canaux RVB (rouge, vert, bleu - rouge, vert, bleu), et Open CV fonctionne avec l'ordre des canaux BGR (bleu, vert, rouge).

Vous pouvez résoudre ce problème avant de sortir le cadre, puis envoyer le cadre comme ceci:

frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
fake.schedule_frame(frame)

Voici le code de script complet fakecam/fake.py:

import os
import cv2
import numpy as np
import requests
import pyfakewebcam

def get_mask(frame, bodypix_url='http://localhost:9000'):
    _, data = cv2.imencode(".jpg", frame)
    r = requests.post(
        url=bodypix_url,
        data=data.tobytes(),
        headers={'Content-Type': 'application/octet-stream'})
    mask = np.frombuffer(r.content, dtype=np.uint8)
    mask = mask.reshape((frame.shape[0], frame.shape[1]))
    return mask

def post_process_mask(mask):
    mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
    mask = cv2.blur(mask.astype(float), (30,30))
    return mask

def shift_image(img, dx, dy):
    img = np.roll(img, dy, axis=0)
    img = np.roll(img, dx, axis=1)
    if dy>0:
        img[:dy, :] = 0
    elif dy<0:
        img[dy:, :] = 0
    if dx>0:
        img[:, :dx] = 0
    elif dx<0:
        img[:, dx:] = 0
    return img

def hologram_effect(img):
    #    
    holo = cv2.applyColorMap(img, cv2.COLORMAP_WINTER)
    #   
    bandLength, bandGap = 2, 3
    for y in range(holo.shape[0]):
        if y % (bandLength+bandGap) < bandLength:
            holo[y,:,:] = holo[y,:,:] * np.random.uniform(0.1, 0.3)
    #  
    holo_blur = cv2.addWeighted(holo, 0.2, shift_image(holo.copy(), 5, 5), 0.8, 0)
    holo_blur = cv2.addWeighted(holo_blur, 0.4, shift_image(holo.copy(), -5, -5), 0.6, 0)
    #     
    out = cv2.addWeighted(img, 0.5, holo_blur, 0.6, 0)
    return out

def get_frame(cap, background_scaled):
    _, frame = cap.read()
    #      (  ,   )
    #       
    mask = None
    while mask is None:
        try:
            mask = get_mask(frame)
        except requests.RequestException:
            print("mask request failed, retrying")
    # -   
    mask = post_process_mask(mask)
    frame = hologram_effect(frame)
    #     
    inv_mask = 1-mask
    for c in range(frame.shape[2]):
        frame[:,:,c] = frame[:,:,c]*mask + background_scaled[:,:,c]*inv_mask
    return frame

#     
cap = cv2.VideoCapture('/dev/video0')
height, width = 720, 1280
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
cap.set(cv2.CAP_PROP_FPS, 60)

#   
fake = pyfakewebcam.FakeWebcam('/dev/video20', width, height)

#    
background = cv2.imread("/data/background.jpg")
background_scaled = cv2.resize(background, (width, height))

#    
while True:
    frame = get_frame(cap, background_scaled)
    #    RGB-
    frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    fake.schedule_frame(frame)

Maintenant, rassemblez les images:

docker build -t bodypix ./bodypix
docker build -t fakecam ./fakecam

Exécutez-les:

#  
docker network create --driver bridge fakecam
#   bodypix
docker run -d \
  --name=bodypix \
  --network=fakecam \
  --gpus=all --shm-size=1g --ulimit memlock=-1 --ulimit stack=67108864 \
  bodypix
#  ,  ,      ,  ,
#           
# ,     `sudo groupadd $USER video`
docker run -d \
  --name=fakecam \
  --network=fakecam \
  -p 8080:8080 \
  -u "$$(id -u):$$(getent group video | cut -d: -f3)" \
  $$(find /dev -name 'video*' -printf "--device %p ") \
  fakecam

Il ne reste plus qu'à considérer que cela doit être démarré avant l'ouverture de la caméra lorsque vous travaillez avec des applications. Et dans Zoom ou ailleurs, vous devez sélectionner une caméra v4l2loopback/ /dev/video20.

Sommaire


Voici un clip qui montre les résultats de mon travail.


Résultat du changement de fond

Voir! J'appelle depuis le Millennium Falcon en utilisant la pile de technologies open source pour travailler avec la caméra!

Ce que j'ai fait, j'ai vraiment aimé. Et je vais certainement profiter de tout cela lors de la prochaine vidéoconférence.

Chers lecteurs! Envisagez-vous de changer ce qui est visible pendant les appels vidéo derrière vous pour autre chose?


All Articles