Videollamadas con fondo virtual y herramientas de código abierto

Ahora que muchos de nosotros estamos en cuarentena debido a COVID-19 , las videollamadas se han vuelto mucho más comunes que antes. En particular, el servicio ZOOM de repente se hizo muy popular. Probablemente la característica de Zoom más interesante es la compatibilidad con el Fondo virtual . Permite a los usuarios reemplazar interactivamente el fondo detrás de ellos con cualquier imagen o video.



He estado usando Zoom durante mucho tiempo en el trabajo, en reuniones de código abierto sobre Kubernetes, generalmente usando una computadora portátil corporativa. Ahora, en el modo de trabajo desde casa, me inclino a usar una computadora de escritorio personal más potente y conveniente para resolver algunas de mis tareas de código abierto.

Desafortunadamente, Zoom solo admite un método de eliminación de fondo conocido como " clave de croma " o " pantalla verde ". Para utilizar este método, es necesario que el fondo esté representado por un color sólido, idealmente verde, y esté iluminado de manera uniforme.

Como no tengo una pantalla verde, decidí simplemente implementar mi propio sistema de eliminación de fondo. Y esto, por supuesto, es mucho mejor que poner las cosas en orden en el apartamento, o el uso constante de una computadora portátil de trabajo.

Al final resultó que, usando componentes de código abierto listos para usar y escribiendo solo unas pocas líneas de su propio código, puede obtener resultados muy decentes.

Leer datos de la cámara


Comencemos desde el principio y respondamos la siguiente pregunta: "¿Cómo obtener video de una cámara web que procesaremos?"

Como uso Linux en la computadora de mi casa (cuando no juego), decidí usar los enlaces Open CV Python , que ya conozco. Además de los enlaces V4L2 para leer datos de una cámara web, incluyen funciones básicas útiles de procesamiento de video.

Leer un marco desde una cámara web en python-opencv es muy simple:

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

Para mejorar los resultados al trabajar con mi cámara, apliqué la siguiente configuración antes de capturar video de ella:

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

Parece que la mayoría de los programas de videoconferencia limitan el video a 720p a 30 FPS o menos. Pero, en cualquier caso, es posible que no leamos todos los cuadros. Dichas configuraciones establecen el límite superior.

Ponga el mecanismo de captura de cuadros en un bucle. ¡Ahora tenemos acceso a la transmisión de video desde la cámara!

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

Puede guardar el marco para fines de prueba de la siguiente manera:

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

Después de eso, podemos asegurarnos de que la cámara esté funcionando. ¡Excelente!


Espero que no estes en contra de mi barba

Detección de fondo


Ahora que tenemos acceso a la transmisión de video, pensaremos en cómo detectar el fondo haciendo posible reemplazarlo al encontrarlo. Pero esta ya es una tarea bastante difícil.

Aunque existe la sensación de que los creadores de Zoom nunca hablan exactamente de cómo el programa elimina el fondo, la forma en que se comporta el sistema me hace pensar en lo que podría haber hecho sin redes neuronales. Es difícil de explicar, pero los resultados se ven exactamente así. Además, encontré un artículo sobre cómo Microsoft Teams implementa el desenfoque de fondo utilizando una red neuronal convolucional .

En principio, crear su propia red neuronal no es tan difícil. Hay muchos artículos y artículos científicos sobre segmentación de imágenes.. Hay muchas bibliotecas y herramientas de código abierto. Pero necesitamos un conjunto de datos muy especializado para obtener buenos resultados.

En particular, necesitamos muchas imágenes que recuerden a las obtenidas de una cámara web, con una imagen perfecta de una persona en primer plano. Cada píxel de dicha imagen debe marcarse como diferente del fondo.

Construir un conjunto de datos como preparación para entrenar una red neuronal puede no requerir mucho esfuerzo. Esto se debe al hecho de que el equipo de investigadores de Google ya ha hecho todo lo posible y ha puesto en código abierto una red neuronal pre-entrenada para segmentar a las personas. Esta red se llama BodyPix . ¡Funciona muy bien!

BodyPix ahora solo está disponible en una forma adecuada para TensorFlow.js. Como resultado, es más fácil de aplicar usando la biblioteca body-pix-node .

Para acelerar la salida de la red (pronóstico) en el navegador, es preferible usar el backend WebGL , pero en el entorno Node.js puede usar el backend GPU Tensorflow (tenga en cuenta que esto requerirá una tarjeta de video de NVIDIA , que tengo).

Para simplificar la configuración del proyecto, utilizaremos un pequeño entorno en contenedores que proporciona la GPU TensorFlow y Node.js. Utilizándolo todo con nvidia-docker- mucho más fácil que recopilar las dependencias necesarias en su computadora usted mismo. Para hacer esto, solo necesita Docker y los últimos controladores de gráficos en su computadora.

Aquí está el contenido del archivo bodypix/package.json:

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

Aquí está el archivo 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

Ahora hablemos de obtener resultados. Pero te advierto de inmediato: ¡no soy un experto en Node.js! Esto es solo el resultado de mis experimentos nocturnos, así que sé indulgente conmigo :-).

El siguiente script simple está ocupado procesando una imagen de máscara binaria enviada al servidor utilizando una solicitud HTTP POST. Una máscara es una matriz bidimensional de píxeles. Los píxeles representados por ceros son el fondo.

Aquí está el código del archivo 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);
})();

Para convertir un marco en una máscara, nosotros, en un script de Python, podemos usar los paquetes numpy y 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

El resultado es aproximadamente el siguiente.


Máscara

Mientras hacía todo esto, me encontré con el siguiente tweet.


Este es definitivamente el mejor fondo para las videollamadas.

Ahora que tenemos una máscara para separar el primer plano del fondo, reemplazar el fondo con algo más será muy simple.

Tomé la imagen de fondo de la rama de tweet y la corté para obtener una imagen de 16x9.


Imagen de fondo

Después de eso hice lo siguiente:

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

Eso es lo que obtuve después de eso.


El resultado de reemplazar el fondo

Obviamente, esta máscara no es lo suficientemente precisa, la razón de esto son las compensaciones de rendimiento que hicimos al configurar BodyPix. En general, mientras todo parece más o menos tolerante.

Pero cuando miré este fondo, se me ocurrió una idea.

Experimentos interesantes


Ahora que hemos descubierto cómo enmascarar, preguntaremos cómo mejorar el resultado.

El primer paso obvio es suavizar los bordes de la máscara. Por ejemplo, esto se puede hacer así:

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

Esto mejorará un poco la situación, pero no hay mucho progreso. Y un reemplazo simple es bastante aburrido. Pero, dado que llegamos a todo esto nosotros mismos, esto significa que podemos hacer cualquier cosa con la imagen, y no solo eliminar el fondo.

Dado que estamos usando un fondo virtual de Star Wars, decidí crear un efecto de holograma para hacer que la imagen sea más interesante. Esto, además, le permite suavizar el desenfoque de la máscara.

Primero, actualice el código de procesamiento posterior:

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

Los bordes ahora están borrosos. Esto es bueno, pero aún necesitamos crear un efecto de holograma.

Los hologramas de Hollywood generalmente tienen las siguientes propiedades:

  • Una imagen en color pálido o monocromo, como dibujada por un láser brillante.
  • Un efecto que recuerda a las líneas de exploración o algo parecido a una cuadrícula, como si la imagen se mostrara en varios rayos.
  • “Efecto fantasma”: como si la proyección se realizara en capas o como si no se mantuviera la distancia correcta a la que debería mostrarse al crear la proyección.

Todos estos efectos se pueden implementar paso a paso.

Primero, para colorear la imagen en un tono de azul, podemos usar el método applyColorMap:

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

A continuación, agregue una línea de barrido con un efecto que recuerde dejar en tono medio:

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

A continuación, implementamos el "efecto fantasma" agregando copias ponderadas desplazadas del efecto actual a la imagen:

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

Y finalmente, queremos mantener algunos de los colores originales, por lo que combinamos el efecto holográfico con el marco original, haciendo algo similar a agregar el "efecto fantasma":

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

Así es como se ve un cuadro con un efecto de holograma:


Un cuadro con efecto de holograma

Este cuadro en sí se ve bastante bien.

Ahora intentemos combinarlo con el fondo.


Superpuesto imagen en el fondo.

Hecho! (Lo prometo, este tipo de video se verá más interesante).

Salida de video


Y ahora debo decir que nos hemos perdido algo aquí. El hecho es que todavía no podemos usar todo esto para hacer videollamadas.

Para solucionar esto, utilizaremos pyfakewebcam y v4l2loopback para crear una cámara web falsa.

Además, planeamos conectar esta cámara al Docker.

Primero, cree un archivo de fakecam/requirements.txtdescripción de dependencia:

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

Ahora cree un archivo fakecam/Dockerfilepara la aplicación que implemente las capacidades de una cámara falsa:

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

Ahora, desde la línea de comandos, instale v4l2loopback:

sudo apt install v4l2loopback-dkms

Configurar una cámara falsa:

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

Para garantizar la funcionalidad de algunas aplicaciones (Chrome, Zoom), necesitamos una configuración exclusive_caps. La marca card_labelse establece solo para garantizar la conveniencia de elegir una cámara en las aplicaciones. La indicación del número video_nr=20lleva a la creación del dispositivo /dev/video20si el número correspondiente no está ocupado y es poco probable que esté ocupado.

Ahora haremos cambios en el script para crear una cámara falsa:

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

Cabe señalar que pyfakewebcam espera imágenes con canales RGB (Rojo, Verde, Azul - rojo, verde, azul), y Open CV funciona con el orden de los canales BGR (Azul, Verde, Rojo).

Puede arreglar esto antes de generar el marco y luego enviar el marco de esta manera:

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

Aquí está el código completo del script 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)

Ahora recoge las imágenes:

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

Ejecútalos:

#  
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

Solo queda considerar que esto debe iniciarse antes de abrir la cámara cuando se trabaja con cualquier aplicación. Y en Zoom o en otro lugar, debe seleccionar una cámara v4l2loopback/ /dev/video20.

Resumen


Aquí hay un clip que muestra los resultados de mi trabajo.


Resultado de cambio de fondo

Ver! ¡Llamo desde el Halcón Milenario usando la pila de tecnología de código abierto para trabajar con la cámara!

Lo que hice me gustó mucho. Y definitivamente aprovecharé todo esto en la próxima videoconferencia.

¡Queridos lectores! ¿Estás planeando cambiar lo que está visible durante las videollamadas detrás de ti por algo más?


All Articles