Многие задаются вопросом — насколько медленный Python в операциях декодирования? Правда ли, что компилируемые языки дают прирост скорости во всем, чего касаются? Что быстрее: OpenCV или ничего? Ответы на эти и другие бесполезные вопросы под катом вы прочитать не сможете. Там обычное скучное исследование производительности в конкретной задаче.
Все заинтересовавшиеся, добро пожаловать!
Основная часть проекта, над которым я работаю, состоит в распознавании людей и их действий на видео в реальном времени. Изначально он был написан на Python+OpenCV. Разумеется, в какой-то момент внезапно потребовалось наращивать масштаб, повышать производительность и всячески оптимизировать. И первым делом я решил осмотреться среди библиотек для работы с видеопотоком. А заодно узнать как сильно язык влияет на производительность этой задачи.
Рассматривал самые популярные (на самом деле, выбор не особо велик):
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%
Примеры кода и производительность
Вначале разберем примеры реализации для каждого варианта. Из-за особенностей обработки, описанных выше, тестирование буду проводить в нескольких вариантах:
- Просто получение кадра
- Получение кадра с конвертацией в RGB и GRAY8 (в некоторых задачах для ML достаточно и серого кадра, а данных в нем в 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 все несколько больше кода, но, в це��ом, не сложнее.
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
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 позволяет добиться хорошей производительности и гибкости при написании параллельного или распределённого по сети кода.
Код всех тестов можно посмотреть здесь.
