Библиотеки для декодирования видео. Сравнение на Python и Rust
- пятница, 31 января 2020 г. в 00:26:07
Многие задаются вопросом — насколько медленный Python в операциях декодирования? Правда ли, что компилируемые языки дают прирост скорости во всем, чего касаются? Что быстрее: OpenCV или ничего? Ответы на эти и другие бесполезные вопросы под катом вы прочитать не сможете. Там обычное скучное исследование производительности в конкретной задаче.
Все заинтересовавшиеся, добро пожаловать!
Основная часть проекта, над которым я работаю, состоит в распознавании людей и их действий на видео в реальном времени. Изначально он был написан на Python+OpenCV. Разумеется, в какой-то момент внезапно потребовалось наращивать масштаб, повышать производительность и всячески оптимизировать. И первым делом я решил осмотреться среди библиотек для работы с видеопотоком. А заодно узнать как сильно язык влияет на производительность этой задачи.
Рассматривал самые популярные (на самом деле, выбор не особо велик):
VLC и Valkka отпали практически сразу. Первый без вызова графического интерфейса так и не заработал. На второй крайне мало документации и еще меньше библиотек для других языков.
А вот о первых трех я расскажу поподробнее и сравню их производительность на Python.
После запроса кадра мы сразу получаем готовый numpy.ndarray, да еще и в BGR (к слову операция преобразования в RGB достаточно быстрая). И если для простой программы проблем нет, то для более сложной, когда производительности одного ядра не хватает, начинаются проблемы. А любая попытка распараллелить обработку с помощью библиотеки мультипроцессинга начинает забирать дополнительные ресурсы, так как при передаче между процессами объекты в python должны быть pickable.
Это означает, что библиотека, например, при добавлении в Queue, для передаваемых объектов автоматически выполняет pickle.dumps() и pickle.loads() (к слову в версии Python 3.8 пообещали эту проблему исправить через shared memory). Это довольно накладно для FullHD кадров. В качестве ndarray кадр занимает ~6мб оперативной памяти.
То же происходит и при попытке передать эти данные по сети. Полученный numpy.ndarray нужно преобразовать в bytes перед отправкой, на что тратится довольно значительное количество процессорного времени.
Вначале разберем примеры реализации для каждого варианта. Из-за особенностей обработки, описанных выше, тестирование буду проводить в нескольких вариантах:
Для 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 все несколько больше кода, но, в целом, не сложнее.
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.
Параметры сравнения:
У 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
Здесь опций мало. Мы можем замерить только получение кадра и конвертацию в два цветовых пространства с эмуляцией через 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 |
Здесь опций уже больше. Потому что появляется возможность использовать встроенный преобразователь цветового пространства или использовать встроенный в 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 кадра, а цветоразностную схему просто выбрасываем.
Те же самые возможности, что и 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.
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
}
}
}
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 позволяет добиться хорошей производительности и гибкости при написании параллельного или распределённого по сети кода.
Код всех тестов можно посмотреть здесь.