مكالمات فيديو بخلفية افتراضية وأدوات مفتوحة المصدر

الآن بعد أن تم عزل الكثير منا بسبب COVID-19 ، أصبحت مكالمات الفيديو أكثر شيوعًا من ذي قبل. على وجه الخصوص ، أصبحت خدمة ZOOM فجأة تحظى بشعبية كبيرة. ربما تكون ميزة التكبير / التصغير الأكثر إثارة للاهتمام هي دعم الخلفية الافتراضية . يسمح للمستخدمين باستبدال الخلفية خلفهم بشكل تفاعلي بأي صورة أو فيديو.



لقد كنت أستخدم Zoom في العمل لفترة طويلة ، في اجتماعات مفتوحة المصدر على Kubernetes ، وعادة ما أقوم بذلك من كمبيوتر محمول للشركات. الآن ، عندما أعمل من المنزل ، أميل إلى استخدام كمبيوتر سطح مكتب شخصي أكثر قوة وملاءمة لحل بعض المهام المفتوحة المصدر.

لسوء الحظ ، يدعم Zoom فقط طريقة إزالة الخلفية المعروفة باسم " مفتاح chroma " أو " الشاشة الخضراء ". لاستخدام هذه الطريقة ، من الضروري أن يتم تمثيل الخلفية ببعض الألوان الصلبة ، والأخضر بشكل مثالي ، وأن تكون مضاءة بشكل موحد.

نظرًا لعدم وجود شاشة خضراء ، فقد قررت ببساطة تنفيذ نظام إزالة الخلفية الخاص بي. وهذا ، بالطبع ، أفضل بكثير من ترتيب الأشياء في الشقة ، أو الاستخدام المستمر لجهاز كمبيوتر محمول للعمل.

كما اتضح ، باستخدام مكونات مفتوحة المصدر جاهزة وكتابة بضعة أسطر فقط من التعليمات البرمجية الخاصة بك ، يمكنك الحصول على نتائج لائقة للغاية.

قراءة بيانات الكاميرا


لنبدأ من البداية ونجيب على السؤال التالي: "كيف تحصل على فيديو من كاميرا ويب سنعالجه؟"

نظرًا لأنني أستخدم Linux على جهاز الكمبيوتر في المنزل (عندما لا ألعب الألعاب) ، فقد قررت استخدام روابط Open CV Python ، التي أعرفها بالفعل. بالإضافة إلى روابط V4L2 لقراءة البيانات من كاميرا الويب ، فإنها تتضمن وظائف معالجة الفيديو الأساسية المفيدة.

قراءة إطار من كاميرا ويب في python-opencv أمر بسيط للغاية:

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

لتحسين النتائج عند العمل مع الكاميرا ، قمت بتطبيق الإعدادات التالية قبل التقاط الفيديو منها:

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

هناك شعور بأن معظم برامج مؤتمرات الفيديو تقصر الفيديو على 720p @ 30 FPS أو أقل. لكننا ، على أي حال ، قد لا نقرأ كل إطار. تحدد هذه الإعدادات الحد الأعلى.

ضع آلية التقاط الإطار في حلقة. الآن لدينا الوصول إلى دفق الفيديو من الكاميرا!

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

يمكنك حفظ الإطار لأغراض الاختبار على النحو التالي:

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

بعد ذلك ، يمكننا التأكد من أن الكاميرا تعمل. عظيم!


آمل أنك لست ضد لحيتي

كشف الخلفية


الآن بعد أن أصبح لدينا إمكانية الوصول إلى دفق الفيديو ، سنفكر في كيفية اكتشاف الخلفية من خلال تمكينها من استبدالها من خلال العثور عليها. لكن هذه مهمة صعبة بالفعل.

على الرغم من وجود شعور بأن مبدعي Zoom لا يتحدثون أبدًا عن كيفية إزالة البرنامج للخلفية بالضبط ، فإن الطريقة التي يتصرف بها النظام تجعلني أفكر في ما كان يمكن أن يفعله بدون الشبكات العصبية. من الصعب شرح ذلك ، لكن النتائج تبدو هكذا تمامًا. بالإضافة إلى ذلك ، وجدت مقالًا حول كيفية تنفيذ Microsoft Teams لطمس الخلفية باستخدام شبكة عصبية تلافيفية .

من حيث المبدأ ، فإن إنشاء الشبكة العصبية الخاصة بك ليس بالأمر الصعب. هناك العديد من المقالات والأوراق العلمية حول تجزئة الصورة.. هناك الكثير من المكتبات والأدوات مفتوحة المصدر. لكننا نحتاج إلى مجموعة بيانات متخصصة جدًا للحصول على نتائج جيدة.

على وجه الخصوص ، نحتاج إلى الكثير من الصور التي تشبه تلك التي تم الحصول عليها من كاميرا ويب ، مع صورة مثالية لشخص في المقدمة. يجب تمييز كل بكسل في هذه الصورة على أنه مختلف عن الخلفية.

قد لا يتطلب بناء مجموعة البيانات هذه استعدادًا لتدريب شبكة عصبية الكثير من الجهد. ويرجع ذلك إلى حقيقة أن فريق الباحثين من Google قد قام بكل ما هو أصعب بالفعل ووضع شبكة عصبية مدربة مسبقًا لتصنيف الأشخاص إلى المصدر المفتوح. تسمى هذه الشبكة BodyPix . أنه يعمل بشكل جيد جدا!

يتوفر BodyPix الآن فقط في شكل مناسب لـ TensorFlow.js. ونتيجة لذلك ، من الأسهل تقديم الطلب باستخدام مكتبة body-pix-node .

لتسريع إخراج الشبكة (التوقعات) في المتصفح ، يفضل استخدام الواجهة الخلفية WebGL ، ولكن في بيئة Node.js يمكنك استخدام الواجهة الخلفية Tensorflow GPU (لاحظ أن هذا سيتطلب بطاقة فيديو من NVIDIA ، لدي).

من أجل تبسيط إعداد المشروع ، سنستخدم بيئة حاوية صغيرة توفر وحدة معالجة الرسوميات TensorFlow و Node.js. باستخدام كل شيء مع nvidia-docker- أسهل بكثير من جمع التبعيات اللازمة على جهاز الكمبيوتر الخاص بك بنفسك. للقيام بذلك ، ما عليك سوى Docker وأحدث برامج تشغيل الرسومات على جهاز الكمبيوتر الخاص بك.

هنا محتويات الملف bodypix/package.json:

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

هنا الملف 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

الآن دعونا نتحدث عن الحصول على نتائج. لكني أحذرك على الفور: أنا لست خبيرًا في Node.js! هذه مجرد نتيجة لتجاربي المسائية ، لذا كن متساهلاً معي :-).

النص البرمجي البسيط التالي مشغول في معالجة صورة قناع ثنائي تم إرسالها إلى الخادم باستخدام طلب HTTP POST. القناع عبارة عن صفيف ثنائي الأبعاد من وحدات البكسل. البكسلات التي تمثلها الأصفار هي الخلفية.

إليك رمز الملف 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);
})();

لتحويل الإطار إلى قناع، فإننا، في برنامج نصي بيثون، ويمكن استخدام نمباي و طلبات حزم :

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

والنتيجة هي ما يلي تقريبًا.


قناع

بينما كنت أقوم بكل هذا ، صادفتالتغريدة التالية .


هذه بالتأكيد أفضل خلفية لمكالمات الفيديو ،

والآن بعد أن أصبح لدينا قناع لفصل المقدمة عن الخلفية ، فإن استبدال الخلفية بشيء آخر سيكون بسيطًا جدًا.

التقطت صورة الخلفية من فرع التغريدات وقمت بقصها حتى أحصل على صورة 16x9.


صورة الخلفية

بعد ذلك قمت بما يلي:

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

هذا ما حصلت عليه بعد ذلك.


نتيجة استبدال الخلفية من

الواضح أن هذا القناع غير دقيق بما فيه الكفاية ، والسبب في ذلك هو مقايضات الأداء التي قمنا بها عند إعداد BodyPix. بشكل عام ، بينما يبدو كل شيء أكثر أو أقل تسامحًا.

ولكن ، عندما نظرت إلى هذه الخلفية ، جاءت لي فكرة واحدة.

تجارب مثيرة للاهتمام


الآن بعد أن اكتشفنا كيفية الإخفاء ، سنسأل عن كيفية تحسين النتيجة.

الخطوة الأولى الواضحة هي تنعيم حواف القناع. على سبيل المثال ، يمكن القيام بذلك على النحو التالي:

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

سيؤدي ذلك إلى تحسين الوضع قليلاً ، ولكن ليس هناك تقدم كبير. وبديل بسيط ممل للغاية. ولكن ، بما أننا وصلنا إلى كل هذا بأنفسنا ، فهذا يعني أنه يمكننا فعل أي شيء بالصورة ، وليس فقط إزالة الخلفية.

نظرًا لأننا نستخدم خلفية افتراضية من Star Wars ، فقد قررت إنشاء تأثير الهولوغرام لجعل الصورة أكثر إثارة للاهتمام. هذا ، بالإضافة إلى ذلك ، يسمح لك بتخفيف ضبابية القناع.

أولاً ، حدِّث رمز ما بعد المعالجة:

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

الحواف الآن ضبابية. هذا أمر جيد ، لكننا ما زلنا بحاجة إلى إنشاء تأثير الهولوغرام.

عادةً ما تحتوي الصور المجسمة لهوليوود على الخصائص التالية:

  • صورة باهتة أو أحادية اللون - كما لو تم رسمها بواسطة ليزر ساطع.
  • تأثير يذكرنا بخطوط المسح أو شيء يشبه الشبكة - كما لو كانت الصورة معروضة بعدة أشعة.
  • "تأثير الأشباح" - كما لو أن الإسقاط يتم في طبقات أو كما لو أن المسافة الصحيحة التي يجب عرضها عندها لم يتم الحفاظ عليها أثناء إنشاء الإسقاط.

يمكن تنفيذ كل هذه الآثار خطوة بخطوة.

أولاً ، لتلوين الصورة بظل أزرق ، يمكننا استخدام الطريقة applyColorMap:

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

التالي - أضف خط مسح مع تأثير يذكرنا بالمغادرة في نصف الألوان:

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

بعد ذلك ، نقوم بتطبيق "تأثير الشبح" بإضافة نسخ مرجحة من التأثير الحالي للصورة:

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

وأخيرًا ، نريد الاحتفاظ ببعض الألوان الأصلية ، لذلك نجمع بين التأثير المجسم والإطار الأصلي ، ونفعل شيئًا مشابهًا لإضافة "تأثير الشبح":

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

إليك ما يبدو عليه الإطار ذو تأثير الهولوغرام:


إطار له تأثير الهولوغرام

يبدو هذا الإطار نفسه جيدًا.

الآن دعنا نحاول دمجه مع الخلفية.


صورة مضافين على الخلفية.

القيام به! (أعدك - سيبدو هذا النوع من الفيديو أكثر إثارة للاهتمام).

إخراج الفيديو


والآن يجب أن أقول أننا فاتنا شيء هنا. الحقيقة هي أننا ما زلنا لا نستطيع استخدام كل هذه لإجراء مكالمات الفيديو.

لإصلاح ذلك ، سنستخدم pyfakewebcam و v4l2loopback لإنشاء كاميرا ويب وهمية.

بالإضافة إلى ذلك ، نخطط لتوصيل هذه الكاميرا إلى Docker.

أولاً ، أنشئ ملف fakecam/requirements.txtوصف التبعية:

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

الآن قم بإنشاء ملف fakecam/Dockerfileللتطبيق الذي يطبق قدرات كاميرا وهمية:

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

الآن ، من سطر الأوامر ، قم بتثبيت v4l2loopback:

sudo apt install v4l2loopback-dkms

قم بإعداد كاميرا وهمية:

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

لضمان عمل بعض التطبيقات (Chrome ، Zoom) ، نحتاج إلى إعداد exclusive_caps. card_labelتم تعيين العلامة فقط لضمان راحة اختيار الكاميرا في التطبيقات. video_nr=20يؤدي تحديد الرقم إلى إنشاء الجهاز /dev/video20إذا كان الرقم المقابل غير مشغول ، ومن غير المحتمل أن يكون مشغولًا.

الآن سنقوم بإجراء تغييرات على البرنامج النصي لإنشاء كاميرا وهمية:

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

وتجدر الإشارة إلى أن pyfakewebcam تتوقع صورًا بقنوات RGB (أحمر ، أخضر ، أزرق - أحمر ، أخضر ، أزرق) ، وتعمل السيرة الذاتية المفتوحة بترتيب قنوات BGR (أزرق ، أخضر ، أحمر).

يمكنك إصلاح هذا قبل إخراج الإطار ، ثم إرسال الإطار مثل هذا:

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

إليك رمز النص الكامل 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)

الآن جمع الصور:

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

قم بتشغيلها:

#  
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

يبقى فقط أن نأخذ في الاعتبار أن هذا يجب أن يبدأ قبل فتح الكاميرا عند العمل مع أي تطبيقات. وفي تكبير أو في مكان آخر تحتاج إلى تحديد كاميرا v4l2loopback/ /dev/video20.

ملخص


هنا مقطع يوضح نتائج عملي.


نتيجة تغيير الخلفية

انظر! أتصل من Millennium Falcon باستخدام مكدس التكنولوجيا مفتوح المصدر للعمل مع الكاميرا!

ما فعلته ، لقد أحببت حقًا. وسأستفيد بالتأكيد من كل هذا في مؤتمر الفيديو التالي.

القراء الأعزاء! هل تخطط لتغيير ما هو مرئي أثناء مكالمات الفيديو خلفك لشيء آخر؟


All Articles