Как стать автором
Обновить

Библиотеки для декодирования видео. Сравнение на Python и Rust

Время на прочтение12 мин
Количество просмотров19K

Многие задаются вопросом — насколько медленный Python в операциях декодирования? Правда ли, что компилируемые языки дают прирост скорости во всем, чего касаются? Что быстрее: OpenCV или ничего? Ответы на эти и другие бесполезные вопросы под катом вы прочитать не сможете. Там обычное скучное исследование производительности в конкретной задаче.
Все заинтересовавшиеся, добро пожаловать!


Основная часть проекта, над которым я работаю, состоит в распознавании людей и их действий на видео в реальном времени. Изначально он был написан на Python+OpenCV. Разумеется, в какой-то момент внезапно потребовалось наращивать масштаб, повышать производительность и всячески оптимизировать. И первым делом я решил осмотреться среди библиотек для работы с видеопотоком. А заодно узнать как сильно язык влияет на производительность этой задачи.


Рассматривал самые популярные (на самом деле, выбор не особо велик):


  1. OpenCV
  2. FFmpeg
  3. GStreamer
  4. VLC
  5. Valkka

VLC и Valkka отпали практически сразу. Первый без вызова графического интерфейса так и не заработал. На второй крайне мало документации и еще меньше библиотек для других языков.


А вот о первых трех я расскажу поподробнее и сравню их производительность на Python.


OpenCV


Плюсы


  • Крайне простой интерфейс взаимодействия
  • Наличие библиотеки для Python с кучей СV функций
  • Отличная производительность

Минусы


  • Отсутствие возможности нормально управлять процессом получения и декодирования кадров с rtsp-потока. И если для FFmpeg через переменную окружения еще можно задать несколько параметров, то для GStreamer вообще нельзя. Хотя и этот малый набор не спасает никак
  • Использование FFmpeg в качестве бэкенда по-умолчанию (подробности в разделе про FFmpeg)
  • Из-за упрощённого интерфейса нет возможности получить кадр на промежуточном этапе

После запроса кадра мы сразу получаем готовый numpy.ndarray, да еще и в BGR (к слову операция преобразования в RGB достаточно быстрая). И если для простой программы проблем нет, то для более сложной, когда производительности одного ядра не хватает, начинаются проблемы. А любая попытка распараллелить обработку с помощью библиотеки мультипроцессинга начинает забирать дополнительные ресурсы, так как при передаче между процессами объекты в python должны быть pickable.


Это означает, что библиотека, например, при добавлении в Queue, для передаваемых объектов автоматически выполняет pickle.dumps() и pickle.loads() (к слову в версии Python 3.8 пообещали эту проблему исправить через shared memory). Это довольно накладно для FullHD кадров. В качестве ndarray кадр занимает ~6мб оперативной памяти.


То же происходит и при попытке передать эти данные по сети. Полученный numpy.ndarray нужно преобразовать в bytes перед отправкой, на что тратится довольно значительное количество процессорного времени.


FFmpeg


Плюсы


  • Самое быстрое декодирование из тройки
  • Возможность управлять процессом получения и декодирования
  • Большое количество документации

Минусы


  • Проблема упомянутая в разделе OpenCV — отвратительное качество работы с нестабильным rtsp-потоком: большое количество битых кадров при перегрузке как источника, так и получателя. Для нейросетей это критично, потому что понять, что кадр битый не так-то просто, а обученная нейросеть в этом шуме может увидеть что-нибудь с вероятностью сильно отличной от нуля
  • Еще один немаловажный фактор — отсутствие нативной библиотеки для работы с Python. На выбор всего два варианта: либо получать байты через linux pipe, либо писать их в zram хранилище и читать. В общем, оба варианта так себе

GStreamer


Плюсы


  • Хорошие библиотеки для Python и Rust
  • Богатый набор элементов для построения конвейера декодирования. Можно декодированный кадр разветвлять на несколько конвейеров и обрабатывать по-разному
  • Отлично работает с rtsp, умеет изменять входную задержку (что очень важно для систем реагирования в реальном времени), сбрасывать битые кадры

Минусы


  • Плохое качество документации
  • По разным бенчмаркам медленнее, чем FFmpeg примерно на 5-10%

Примеры кода и производительность


Вначале разберем примеры реализации для каждого варианта. Из-за особенностей обработки, описанных выше, тестирование буду проводить в нескольких вариантах:


  1. Просто получение кадра
  2. Получение кадра с конвертацией в RGB и GRAY8 (в некоторых задачах для ML достаточно и серого кадра, а данных в нем в 3 раза меньше)
  3. Получение кадра, конвертация и сериализация/десериализация через pickle

Для OpenCV объяснения вряд ли требуются. Статей о начале работы с этой библиотекой — полный хабр.


import pickle
import cv2

source = 'Tractor_500kbps_x264.mp4'
cap = cv2.VideoCapture(source)
while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break
    pickle.loads(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).dumps())
cap.release()

Для FFmpeg есть несколько библиотек для Python (ffmpeg-python, scikit-video, ffmpy, ffmpeg). Лучшая, на мой взгляд, ffmpeg-python: хорошая документация, удобный синтаксис запросов. Но все эти библиотеки — только обертка поверх консольного вызова через subprocess.Popen и последующей передачей данных через linux pipe в stdout, а код на Python уже слушает stdout и превращает данные в numpy ndarray.


Выглядит это примерно следующим образом:


from subprocess import Popen, PIPE, DEVNULL
import numpy as np
import cv2

source = 'Tractor_500kbps_x264.mp4'
width, height = (1920, 1080)
stream_url = f'ffmpeg -vcodec h264 -i {source} -f rawvideo -pix_fmt yuv420p pipe:'.split(' ')
with Popen(stream_url, stdout=PIPE, stderr=DEVNULL) as p:
    while p.stdout.readable():
        yuv_height = int(height+height//2)
        raw_frame = p.stdout.read(yuv_height * width)
        if len(raw_frame) == yuv_height * width:
            frame = np.frombuffer(raw_frame, np.uint8).reshape((yuv_height, width))
            cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
        else:
            break
    p.stdout.flush()

В приведенном выше коде есть несколько интересных моментов. Например, чтобы прочитать bytearray нужно знать разрешение кадра. Можно, конечно, воспользоваться ffprobe и так же, запустив его в отдельном субпроцессе, получить эти данные перед началом обработки. Но это крайне неудобно, и время запуска удваивается, т. к. ffprobe должен подключиться к rtsp-потоку и взять один кадр.


Второй момент — это цветовое пространство. По умолчанию все видео файлы и видеопотоки используют подмножество YUV, чаще это I420 (YUV420p), кадр которого состоит из полного кадра яркости и двух цветоразностных полукадров. А модели свёрточных нейросетей чаще всего обучены на RGB.


Кстати, декодирование кадра на GPU (например на NVDEC) преобразует кадр в цветовое пространство NV12 которое тоже является подмножеством YUV. В случае с ffmpeg мы можем задать преобразование через него или использовать функции OpenCV. Разницу будет видно в тестах производительности.


C GStreamer все несколько больше кода, но, в целом, не сложнее.


Большой кусок кода для GStreamer
import numpy as np
import cv2
import gi

gi.require_version('Gst', '1.0')
gi.require_version('GLib', '2.0')
gi.require_version('GObject', '2.0')

from gi.repository import GLib, Gst

def bus_call(bus, message, loop, pipe):
    t = message.type
    if t == Gst.MessageType.EOS:
        pipe.set_state(Gst.State.NULL)
        loop.quit()
    elif t == Gst.MessageType.ERROR:
        err, debug = message.parse_error()
        print(f'{err}: {debug}')
        pipe.set_state(Gst.State.NULL)
        loop.quit()
    return Gst.FlowReturn.OK

def yuv_rgb(appsink):
    sample = appsink.emit("pull-sample")
    buf = sample.get_buffer()
    caps = sample.get_caps()
    height = caps.get_structure(0).get_value('height')
    width = caps.get_structure(0).get_value('width')
    stream_format = caps.get_structure(0).get_value('format')
    data = buf.extract_dup(0, buf.get_size())
    if data:
        frame = np.frombuffer(data, np.uint8).reshape((height+height//2, width))
        cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
    return Gst.FlowReturn.OK

def pipe_init(source, on_new_sample, pix_format):
    Gst.init(None)
    pipe = Gst.Pipeline.new('dynamic')

    src = Gst.ElementFactory.make('filesrc')
    demux = Gst.ElementFactory.make('qtdemux')
    parse = Gst.ElementFactory.make('h264parse')
    decode = Gst.ElementFactory.make('avdec_h264')
    convert = Gst.ElementFactory.make('videoconvert')
    sink = Gst.ElementFactory.make('appsink')

    for item in (src, demux, parse, decode, convert, sink):
        pipe.add(item)

    src.link(demux)
    demux.connect('pad-added', lambda element, pad: element.link(parse))
    parse.link(decode)
    decode.link(convert)
    convert.link(sink)

    src.set_property('location', source)

    sink.set_property("emit-signals", True)
    sink.set_property("max-buffers", 1)
    caps = Gst.caps_from_string(f"video/x-raw, format=(string){pix_format}")
    sink.set_property("caps", caps)
    sink.set_property("drop", True)
    sink.set_property("wait-on-eos", True)
    sink.set_property('sync', False)
    sink.connect("new-sample", on_new_sample)
    return pipe

def run(source, sink_callback, pix_format):
    loop = GLib.MainLoop()
    pipe = pipe_init(source, sink_callback, pix_format)

    bus = pipe.get_bus()
    bus.add_signal_watch()
    bus.enable_sync_message_emission()
    bus.connect('message', bus_call, loop, pipe)

    pipe.set_state(Gst.State.PLAYING)
    loop.run()

run('Tractor_500kbps_x264.mp4', yuv_rgb, 'I420')

У GStreamer отличие в специальном элементе appsink, который вызывает callback-функцию и передает в нее полученный кадр.


Что же с производительностью?


Все варианты тестирования производились на Intel Core i5-6600K, по 10 итераций. Для Python использовалась библиотека timeit. В качестве тестового видео взял стандартное Tractor_500kbps_x264.mp4.


Параметры сравнения:


  • pure — это кадр без конвертации
  • gray8 — конвертация из BGR в GRAY8 средствами OpenCV
  • rgb — конвертация из BGR в RGB средствами OpenCV
  • yuv_gray — яркостная часть кадра от I420
  • yuv_gray8 — конвертация из YUV420p в GRAY8 средствами OpenCV
  • yuv_rgb — конвертация из YUV420p в RGB средствами OpenCV
  • native_gray — конвертация из YUV420p в GRAY8 средствами библиотеки
  • native_rgb — конвертация из YUV420p в RGB средствами библиотеки

Native


У FFmpeg и GStreamer есть возможность проверить производительность обработки, сбрасывая кадры в /dev/null. Эти цифры мы будем считать за базовые.


ffmpeg -vcodec h264 -i Tractor_500kbps_x264.mp4 -f null /dev/null

frame=  252 fps=0.0 q=-0.0 Lsize=N/A time=00:00:10.28 bitrate=N/A speed=24.6x

gst-launch-1.0 filesrc location="Tractor_500kbps_x264.mp4" ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! fakesink

Execution ended after 0:00:00.551382068

OpenCV


Здесь опций мало. Мы можем замерить только получение кадра и конвертацию в два цветовых пространства с эмуляцией через pickle передачи по сети или в соседний процесс. Как видно, на больших объемах данных pickle полностью убивает производительность.


Формат Всего времени Времени на итерацию Кадров в секунду
pure 8.7099с 0.8710с 289.3268 fps
gray8 20.3162с 2.0316с 124.0389 fps
rgb 74.3420с 7.4342с 33.8974 fps

FFmpeg


Здесь опций уже больше. Потому что появляется возможность использовать встроенный преобразователь цветового пространства или использовать встроенный в OpenCV. Также отпадает необходимость сериализовывать кадр через pickle при отправке по сети или в соседний процесс.


Формат Всего времени Времени на итерацию Кадров в секунду
pure 8.3283с 0.8328с 302.5810 fps
yuv_gray 7.3772с 0.7377с 341.5925 fps
yuv_gray8 8.2721с 0.8272с 304.6402 fps
yuv_rgb 9.3969с 0.9397с 268.1733 fps
native_gray 10.7005с 1.0700с 235.5041 fps
native_rgb 13.7820с 1.3782с 182.8466 fps

Как видно, собственная функция преобразования цветового пространства работает помедленнее, чем в OpenCV. yuv_gray — это яркостная составляющая I420 кадра, а цветоразностную схему просто выбрасываем.


GStreamer


Те же самые возможности, что и FFmpeg, только с возможностью большего контроля над процессом.


Формат Всего времени Времени на итерацию Кадров в секунду
pure 7.1359с 0.7136с 353.1457 fps
yuv_gray 6.8841с 0.6884с 366.0609 fps
yuv_gray8 7.3328с 0.7333с 343.6599 fps
yuv_rgb 8.9191с 0.8919с 282.5403 fps
native_gray 20.2932с 2.3832с 105.7409 fps
native_rgb 23.8318с 2.0293с 124.1793 fps

Преобразование цветового пространства еще медленнее, чем в FFmpeg. Остальные форматы чуть быстрее из-за нормального способа передачи кадра.


При обработке через Python теряется примерно 30% производительности относительно тестового случая. Однако, как видно будет дальше, GStreamer, на самом деле, сильно оптимизирует вывод, чем выигрывает больше скорости, и реальная цифра находится в пределах от 10% до 20%. Также в начале мне показалось странным, что преобразование в RGB быстрее, чем в GRAY8. Но это подтверждается тестированием без python:


gst-launch-1.0 filesrc location="Tractor_500kbps_x264.mp4" ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! "video/x-raw, format=(string)GRAY8" ! fakesink

Execution ended after 0:00:02.229323128

gst-launch-1.0 filesrc location="Tractor_500kbps_x264.mp4" ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! "video/x-raw, format=(string)RGB" ! fakesink

Execution ended after 0:00:01.150704119

Казалось бы, возможно, это Python слишком медленный, и можно использовать какой-нибудь компилируемый язык, чтобы выжать еще немножко скорости. В качестве нового модного и молодежного языка будет Rust.


OpenCV


extern crate opencv;

use opencv::{core, videoio, imgproc};

fn main() -> opencv::Result<()> {
    let filename = "Tractor_500kbps_x264.mp4";
    let mut cam = videoio::VideoCapture::new_from_file_with_backend(filename, videoio::CAP_ANY)?;
    let opened = videoio::VideoCapture::is_opened(&cam)?;
    if !opened { panic!("Unable to open default camera!") };
    let mut frame = core::Mat::default()?;
    let mut gray = core::Mat::default()?;
    loop {
        cam.read(&mut frame)?;
        if frame.size()?.width > 0 {
            imgproc::cvt_color(&frame, &mut gray, imgproc::COLOR_BGR2RGB, 0)?;
        }
        else {
            break
        }
    }
}

GStreamer


Большой кусок кода для GStreamer на Rust
extern crate opencv;
use crate::opencv::prelude::Vector;

use std::time::SystemTime;
use opencv::{core, videoio, imgproc};
use opencv::types::{VectorOfint};

extern crate gstreamer as gst;
extern crate gstreamer_app as gst_app;
extern crate failure;
extern crate glib;

use failure::Error;
use gst::prelude::*;

#[macro_use]
extern crate failure_derive;

#[derive(Debug, Fail)]
#[fail(display = "Missing element {}", _0)]
struct MissingElement(&'static str);

struct Camera {
    pipe: gst::Pipeline,
    main_loop: glib::MainLoop,
}

impl Camera {
    fn new(location: &str) -> Camera {
        Camera {
            pipe: Camera::create_pipeline(location).unwrap(),
            main_loop: glib::MainLoop::new(None, false),
        }
    }
    fn run(&self) -> Result<(), Error> {
        self.create_bus()?;
        self.pipe.set_state(gst::State::Playing)?;
        self.main_loop.run();
        Ok(())
    }

    fn create_bus(&self) -> Result<(), Error>{
        let bus = self.pipe.get_bus().expect("Pipeline without bus. Shouldn't happen!");
        let ml = self.main_loop.clone();
        let pipe = self.pipe.clone();
        bus.add_watch(move |_: &gst::Bus, msg: &gst::Message| {
            use gst::MessageView;
            match msg.view() {
                MessageView::Eos(..) => {
                    pipe.set_state(gst::State::Null).unwrap();
                    ml.quit();
                },
                MessageView::Error(err) => {
                    println!(
                        "Error from {:?}: {} ({:?})",
                        err.get_src().map(|s| s.get_path_string()),
                        err.get_error(),
                        err.get_debug()
                    );
                    pipe.set_state(gst::State::Null).unwrap();
                    ml.quit();
                }
                _ => (),
            };
            glib::Continue(true)
        });
        Ok(())
    }
    fn create_pipeline(location: &str) -> Result<gst::Pipeline, Error> {
        gst::init()?;

        let src = gst::ElementFactory::make("filesrc", Some("src"))
            .ok_or(MissingElement("cant create filesource"))?;
        let demux = gst::ElementFactory::make("qtdemux", Some("demux"))
            .ok_or(MissingElement("cant create demux"))?;
        let parse = gst::ElementFactory::make("h264parse", Some("parse"))
            .ok_or(MissingElement("cant create parse"))?;
        let decode = gst::ElementFactory::make("avdec_h264", Some("decode"))
            .ok_or(MissingElement("cant create decodebin"))?;
        let convert = gst::ElementFactory::make("videoconvert", Some("convert"))
            .ok_or(MissingElement("cant create convert"))?;
        let sink = gst::ElementFactory::make("appsink", Some("appsink"))
            .ok_or(MissingElement("cant create appsink"))?;

        src.set_property("location", &location)?;

        let pipeline = gst::Pipeline::new(None);
        pipeline.add_many(&[&src, &demux, &parse, &decode, &convert, &sink])?;
        src.link(&demux)?;
        parse.link(&decode)?;
        decode.link(&convert)?;
        convert.link(&sink)?;

        let sink_pad = parse.get_static_pad("sink").unwrap();
        demux.connect_pad_added(move |_dbin, src_pad| {
            src_pad.link(&sink_pad).expect("Not linked");
        });

        let appsink = sink.dynamic_cast::<gst_app::AppSink>()
            .expect("Sink element is expected to be an appsink!");
        appsink.set_emit_signals(true);
        appsink.set_max_buffers(1);
        appsink.set_drop(true);
        appsink.set_wait_on_eos(false);
        appsink.set_property("sync", &false)?;
        appsink.set_callbacks(
            gst_app::AppSinkCallbacks::new()
                .new_sample(move |appsink: &gst_app::AppSink| {
                    let sample = appsink.pull_sample().ok_or(gst::FlowError::Eos)?;
                    let buffer = sample.get_buffer().ok_or_else(||gst::FlowError::Error)?;
                    let map = buffer.map_readable().ok_or_else(||gst::FlowError::Error)?;
                    let samples = map.as_slice();
                    let dims = VectorOfint::from_iter(vec![1080+1080/2, 1920]);
                    let frame = core::Mat::from_slice(samples).unwrap().reshape_nd(1, &dims).unwrap();
                    let mut rgb = core::Mat::default().unwrap();
                    imgproc::cvt_color(&frame, &mut rgb, imgproc::COLOR_YUV2RGB_I420, 3).unwrap();
                    Ok(gst::FlowSuccess::Ok)
                })
                .build()
        );
        Ok(pipeline)
    }
}

fn main() {
    let filename = "Tractor_500kbps_x264.mp4";
    let camera = Camera::new(filename);
    camera.run().expect("Loop stopped");
}

Библиотека Результат Всего времени Времени на итерацию Кадров в секунду
OpenCV pure 8.733с 0.8733с 288.5606 fps
OpenCV rgb 10.5890с 1.0589с 237.9828 fps
GStreamer pure 5.487с 0.5487с 459.2673 fps
GStreamer yuv_rgb 7.8290с 0.7829с 321.8802 fps

В случае с OpenCV прироста вообще не получилось. А вот GStreamer дает ~15% прироста производительности. Причем основная производительность опять же теряется на конвертации цветового пространства через OpenCV. Основное предположение, что в случае с Python используется библиотека opencv-python из pypi, в составе которой поставляется OpenCV, собранный с оптимизациями. Здесь же используется системный, из репозиториев Arch Linux.


В итоге получается, что комбинация декодирования через GStreamer и конвертации цветого пространства через OpenCV позволяет добиться хорошей производительности и гибкости при написании параллельного или распределённого по сети кода.


Код всех тестов можно посмотреть здесь.

Теги:
Хабы:
Всего голосов 12: ↑12 и ↓0+12
Комментарии10

Публикации

Истории

Работа

Rust разработчик
10 вакансий
Python разработчик
136 вакансий
Data Scientist
60 вакансий

Ближайшие события