Videoanrufe mit virtuellem Hintergrund und Open Source-Tools

Nachdem viele von uns aufgrund von COVID-19 unter Quarantäne gestellt wurden, treten Videoanrufe viel häufiger auf als zuvor. Insbesondere der ZOOM- Dienst wurde plötzlich sehr beliebt. Die wahrscheinlich interessanteste Zoomfunktion ist die Unterstützung des virtuellen Hintergrunds . Benutzer können den Hintergrund dahinter interaktiv durch ein Bild oder Video ersetzen.



Ich benutze Zoom schon lange bei der Arbeit, bei Open-Source-Meetings über Kubernetes, normalerweise mit einem Unternehmens-Laptop. Jetzt bin ich im Betriebsmodus von zu Hause aus geneigt, einen leistungsstärkeren und bequemeren Desktop-Computer zu verwenden, um einige meiner Open-Source-Aufgaben zu lösen.

Leider unterstützt Zoom nur eine Methode zum Entfernen des Hintergrunds, die als " Chroma Key " oder " Green Screen " bezeichnet wird. Um diese Methode anwenden zu können, muss der Hintergrund durch eine feste Farbe, idealerweise grün, dargestellt und gleichmäßig beleuchtet werden.

Da ich keinen grünen Bildschirm habe, habe ich beschlossen, einfach mein eigenes System zum Entfernen des Hintergrunds zu implementieren. Und das ist natürlich viel besser, als die Dinge in der Wohnung in Ordnung zu bringen oder ständig einen Arbeitslaptop zu benutzen.

Wie sich herausstellte, können Sie mit vorgefertigten Open-Source-Komponenten und nur wenigen Zeilen Ihres eigenen Codes sehr gute Ergebnisse erzielen.

Kameradaten lesen


Beginnen wir von vorne und beantworten die folgende Frage: "Wie bekomme ich ein Video von einer Webcam, die wir verarbeiten werden?"

Da ich Linux auf meinem Heimcomputer verwende (wenn ich keine Spiele spiele), habe ich mich für die Open CV Python-Bindungen entschieden , mit denen ich bereits vertraut bin. Neben V4L2- Bindungen zum Lesen von Daten von einer Webcam enthalten sie nützliche grundlegende Videoverarbeitungsfunktionen. Das Lesen eines Frames von einer Webcam in Python-OpenCV ist sehr einfach:



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

Um die Ergebnisse bei der Arbeit mit meiner Kamera zu verbessern, habe ich vor dem Aufnehmen von Videos die folgenden Einstellungen vorgenommen:

#    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)

Es scheint, als würden die meisten Videokonferenzprogramme Videos auf 720p bei 30 FPS oder weniger beschränken. Aber wir können auf jeden Fall nicht jeden Frame lesen. Solche Einstellungen legen die Obergrenze fest.

Legen Sie den Rahmenerfassungsmechanismus in eine Schleife. Jetzt haben wir Zugriff auf den Videostream von der Kamera!

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

Sie können den Frame zu Testzwecken wie folgt speichern:

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

Danach können wir sicherstellen, dass die Kamera funktioniert. Großartig!


Ich hoffe du bist nicht gegen meinen Bart

Hintergrunderkennung


Nachdem wir nun Zugriff auf den Videostream haben, werden wir uns überlegen, wie wir den Hintergrund erkennen können, indem wir ihn durch Auffinden ersetzen können. Dies ist jedoch bereits eine ziemlich schwierige Aufgabe.

Obwohl ich das Gefühl habe, dass die Entwickler von Zoom nie genau darüber sprechen, wie das Programm den Hintergrund entfernt, lässt mich das Verhalten des Systems darüber nachdenken, was ohne neuronale Netze möglich gewesen wäre. Es ist schwer zu erklären, aber die Ergebnisse sehen genau so aus. Außerdem fand ich einen Artikel darüber, wie Microsoft Teams Hintergrundunschärfe mithilfe eines Faltungsnetzwerks implementiert .

Im Prinzip ist es nicht so schwierig, ein eigenes neuronales Netzwerk zu erstellen. Es gibt viele Artikel und wissenschaftliche Arbeiten zur Bildsegmentierung.. Es gibt viele Open Source-Bibliotheken und -Tools. Wir benötigen jedoch einen sehr speziellen Datensatz, um gute Ergebnisse zu erzielen.

Insbesondere benötigen wir viele Bilder, die denen einer Webcam ähneln, wobei ein perfektes Bild einer Person im Vordergrund steht. Jedes Pixel eines solchen Bildes sollte anders als der Hintergrund markiert sein.

Das Erstellen eines solchen Datensatzes zur Vorbereitung des Trainings eines neuronalen Netzwerks erfordert möglicherweise keinen großen Aufwand. Dies liegt an der Tatsache, dass das Forscherteam von Google bereits alles getan hat und ein vorab trainiertes neuronales Netzwerk zur Segmentierung von Personen in Open Source integriert hat. Dieses Netzwerk heißt BodyPix . Es funktioniert sehr gut!

BodyPix ist jetzt nur in einer für TensorFlow.js geeigneten Form verfügbar. Daher ist es am einfachsten, die Body-Pix-Node- Bibliothek zu verwenden .

Um die Netzwerkausgabe (Prognose) im Browser zu beschleunigen , ist es vorzuziehen, das WebGL- Backend zu verwenden. In der Node.js- Umgebung können Sie jedoch das Tensorflow-GPU-Backend verwenden (beachten Sie, dass hierfür eine Grafikkarte von NVIDIA erforderlich ist).

Um die Projekteinrichtung zu vereinfachen, verwenden wir eine kleine Containerumgebung, die die TensorFlow-GPU und Node.js bereitstellt. Alles mit nvidia-docker- viel einfacher als die notwendigen Abhängigkeiten von Ihrem Computer selbst zu sammeln. Dazu benötigen Sie nur Docker und die neuesten Grafiktreiber auf Ihrem Computer.

Hier ist der Inhalt der Datei bodypix/package.json:

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

Hier ist die Datei 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

Lassen Sie uns nun über das Erhalten von Ergebnissen sprechen. Aber ich warne Sie sofort: Ich bin kein Node.js-Experte! Dies ist nur das Ergebnis meiner abendlichen Experimente, also sei mir gegenüber nachsichtig :-).

Das folgende einfache Skript verarbeitet gerade ein Binärmaskenbild, das mithilfe einer HTTP-POST-Anforderung an den Server gesendet wird. Eine Maske ist eine zweidimensionale Anordnung von Pixeln. Durch Nullen dargestellte Pixel bilden den Hintergrund.

Hier ist der Dateicode 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);
})();

Um einen Frame in eine Maske zu konvertieren, können wir in einem Python-Skript die Pakete numpy und request verwenden :

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

Das Ergebnis ist ungefähr das Folgende.


Maske

Während ich das alles machte, stieß ich auf den nächsten Tweet.


Dies ist definitiv der beste Hintergrund für Videoanrufe.

Jetzt, da wir eine Maske haben, um den Vordergrund vom Hintergrund zu trennen, wird es sehr einfach sein, den Hintergrund durch etwas anderes zu ersetzen.

Ich habe das Hintergrundbild aus dem Tweet-Zweig genommen und es so geschnitten, dass ich ein 16x9-Bild bekomme.


Hintergrundbild

Danach habe ich folgendes gemacht:

#    (     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

Das habe ich danach bekommen.


Das Ergebnis des Ersetzens des Hintergrunds

Diese Maske ist offensichtlich nicht genau genug. Der Grund dafür sind die Leistungskompromisse, die wir beim Einrichten von BodyPix eingegangen sind. Im Allgemeinen sieht alles mehr oder weniger tolerant aus.

Aber als ich diesen Hintergrund betrachtete, kam mir eine Idee.

Interessante Experimente


Nachdem wir herausgefunden haben, wie maskiert werden soll, werden wir fragen, wie das Ergebnis verbessert werden kann.

Der erste offensichtliche Schritt besteht darin, die Kanten der Maske weicher zu machen. Dies kann beispielsweise folgendermaßen geschehen:

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

Dies wird die Situation ein wenig verbessern, aber es gibt keine großen Fortschritte. Und ein einfacher Ersatz ist ziemlich langweilig. Da wir dies alles selbst erledigt haben, bedeutet dies, dass wir mit dem Bild alles machen können und nicht nur den Hintergrund entfernen.

Da wir einen virtuellen Hintergrund aus Star Wars verwenden, habe ich beschlossen, einen Hologrammeffekt zu erstellen, um das Bild interessanter zu gestalten. Auf diese Weise können Sie außerdem die Unschärfe der Maske glätten.

Aktualisieren Sie zunächst den Nachbearbeitungscode:

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

Die Kanten sind jetzt verschwommen. Das ist gut, aber wir müssen noch einen Hologrammeffekt erzeugen.

Hollywood-Hologramme haben normalerweise die folgenden Eigenschaften:

  • Eine blasse Farbe oder ein monochromes Bild - wie von einem hellen Laser gezeichnet.
  • Ein Effekt, der an Scanlinien oder so etwas wie ein Gitter erinnert - als würde das Bild in mehreren Strahlen angezeigt.
  • „Ghost-Effekt“ - als ob die Projektion in Ebenen ausgeführt würde oder als ob der richtige Abstand, in dem sie angezeigt werden soll, während der Erstellung der Projektion nicht eingehalten wurde.

All diese Effekte können Schritt für Schritt umgesetzt werden.

Um das Bild in einem Blauton einzufärben, können wir zunächst die folgende Methode verwenden applyColorMap:

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

Weiter - fügen Sie eine Sweep-Linie mit einem Effekt hinzu, der an das Verlassen im Halbton erinnert:

#    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)

Als nächstes implementieren wir den „Ghost-Effekt“, indem wir dem Bild verschobene gewichtete Kopien des aktuellen Effekts hinzufügen:

# 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)

Und schließlich möchten wir einige der Originalfarben beibehalten, also kombinieren wir den holographischen Effekt mit dem Originalrahmen und tun etwas Ähnliches wie das Hinzufügen des „Geistereffekts“:

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

So sieht ein Rahmen mit Hologrammeffekt aus:


Ein Rahmen mit Hologrammeffekt

Dieser Rahmen selbst sieht ziemlich gut aus.

Versuchen wir nun, es mit dem Hintergrund zu kombinieren.


Bild auf dem Hintergrund überlagert.

Fertig! (Ich verspreche - diese Art von Video wird interessanter aussehen).

Video-Ausgang


Und jetzt muss ich sagen, dass wir hier etwas verpasst haben. Tatsache ist, dass wir immer noch nicht alle für Videoanrufe verwenden können.

Um dies zu beheben, verwenden wir pyfakewebcam und v4l2loopback , um eine Dummy-Webcam zu erstellen.

Darüber hinaus planen wir, diese Kamera an den Docker anzuschließen.

Erstellen Sie zunächst eine fakecam/requirements.txtAbhängigkeitsbeschreibungsdatei:

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

Erstellen Sie nun eine Datei fakecam/Dockerfilefür die Anwendung, die die Funktionen einer Dummy-Kamera implementiert:

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

Installieren Sie nun über die Befehlszeile v4l2loopback:

sudo apt install v4l2loopback-dkms

Richten Sie eine Dummy-Kamera ein:

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

Um die Funktionalität einiger Anwendungen (Chrome, Zoom) sicherzustellen, benötigen wir eine Einstellung exclusive_caps. Die Markierung card_labelwird nur gesetzt, um die bequeme Auswahl einer Kamera in Anwendungen zu gewährleisten. Die Angabe der Nummer video_nr=20führt zur Erstellung des Geräts, /dev/video20wenn die entsprechende Nummer nicht besetzt ist und es unwahrscheinlich ist, dass sie besetzt ist.

Jetzt nehmen wir Änderungen am Skript vor, um eine Dummy-Kamera zu erstellen:

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

Es ist zu beachten, dass pyfakewebcam Bilder mit RGB-Kanälen (Rot, Grün, Blau - Rot, Grün, Blau) erwartet und Open CV mit der Reihenfolge der BGR-Kanäle (Blau, Grün, Rot) funktioniert.

Sie können dies beheben, bevor Sie den Frame ausgeben, und den Frame dann wie folgt senden:

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

Hier ist der vollständige Skriptcode 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)

Sammeln Sie nun die Bilder:

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

Führen Sie sie aus:

#  
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

Es bleibt nur zu berücksichtigen, dass dies gestartet werden muss, bevor die Kamera geöffnet wird, wenn mit Anwendungen gearbeitet wird. Und in Zoom oder woanders müssen Sie eine Kamera v4l2loopback/ auswählen /dev/video20.

Zusammenfassung


Hier ist ein Clip, der die Ergebnisse meiner Arbeit demonstriert.


Ergebnis der Hintergrundänderung

Siehe! Ich rufe vom Millennium Falcon aus an und verwende den Open-Source-Technologie-Stack für die Arbeit mit der Kamera!

Was ich getan habe, hat mir sehr gut gefallen. Und das alles werde ich auf jeden Fall bei der nächsten Videokonferenz nutzen.

Liebe Leser! Planen Sie, das, was bei Videoanrufen hinter Ihnen sichtbar ist, für etwas anderes zu ändern?


All Articles