备注:这是Promwad(电子设计公司)的特邀文章,本文介绍了在运行 Linux 的电视盒上使用 Gstreamer 开发视频会议应用程序的基本步骤。
新冠肺炎的流行很显然已经成为新在线服务的催化剂了。例如,作为现代企业视频通讯领域的领导者的 Zoom 做得就非常成功,2020年10月他们的资本总额就超过了全球最大的信息技术和业务解决方案公司—IBM了。
Promwad软件工程师也因此受到了启发,决定向前更进一步:即在智能电视和机顶盒上实现视频会议。这样一来,用户不仅可以在工作中进行交流,而且还可以与朋友进行远程会议,也可以一起看电影、一起为足球队加油或与教练一起运动等等。
由于各种原因,大多数的数字电视运营商是还没有这种服务的。尽管从工程的角度来看,所有这些功能都可以在基于Linux / Android和RDK的机顶盒上实现。
现在,我们一起来分析一下这个类似 Zoom 的智能电视视频会议应用程序的架构,以及它使用 GStreamer开 源多媒体框架来实现的视频流编码。我们收集了一些信息用来分块使用这个框架,这一点是十分值得的。
挑战与软件架构
在为机顶盒开发应用架构时,必须要考虑所有潜在的软件和硬件限制。通常,构建桌面程序的工程师不会面临这些挑战,但这对于嵌入式工程师来说却是司空见惯的事情。
那么,我们会在机顶盒上遇到些什么问题呢?
- CPU和设备本身的资源有限。在大多数情况下,STB设备使用各种ARM处理器,这意味着一些限制对于附加任务,例如需要对视频流使用硬件编码/解码。因此,性能是一个瓶颈。
- 不同制造商在机顶盒中使用不同的架构。有些是基于Android的;其他人使用具有局限性和细微差别的RDK或基于 Linux 的发行版。因此,最好在开发过程开始时选择不同软件模块中最常见和跨平台的解决方案。更不用说支持桌面版了。然后继续讨论一些特殊的案例。
- 网络限制。许多机顶盒都可以通过以太网和Wi-Fi工作。视频/音频流的压缩和传输是此类应用程序中的另一个瓶颈。
- 数据流传输的安全性和其他数据安全性问题。
- 支持嵌入式平台上的摄像头和麦克风在。
现在可对架构本身进行集群,可以看到我们机顶盒视频会议的应用程序主要由几个大型组件和模块组成:
- 视频流捕获
- 音频流捕获
- 网络模块
- 视频/音频流编码模块
- 视音频流解码模块
- 屏幕上显示视频会议
- 声音输出
- 颜色转换
- 其他几个次级组件
架构可以用简化的形式,描述如下:

在本文中,我会重点介绍视频流的解码/编码以及 GStreamer 框架的可实现性,因为它是视频会议应用程序开发中的关键点之一。
音频/视频流编码和解码
GStreamer在视频会议中的优势
正如我之前指出的那样,视频流是我们的瓶颈之一。假设你有一台以每秒 30 帧速度输出素材的 640×480 小分辨率相机。总的来说,在 RGB24 中结果是:640 * 480 * 3 * 30 = 27,648,000 字节每秒,即每秒超过 26 兆字节,这显然是不行的,尤其是网络传输带宽。
解决方案之一是使用某些库来实现视频编码。从本文标题其实可以猜到,我们的选择落就是这个 GStreamer 框架。为什么是这个特殊的库?
下面是它与其他解决方案相比的一些优势:
- 良好的跨平台解决方案,支持Linux和Android
- 在RDK上,Gstreamer是包含在默认发行版中的编码/解码标准
- 它支持各种模块、过滤器和编解码器。例如,可以用于相同目的的FFmpeg(领先的多媒体框架)是GStreamer的模块之一
- 易于建立管道。创建编码/解码链很容易,且管道方法可以平滑替换编解码器、过滤器等,而无需重写代码
- 包含C / C ++ API
- 支持硬件编码器/解码器,尤其是OpenMAX API–与机顶盒配合使用的重要功能
探索GStreamer和管道
在进行代码测评之前,让我们看看没有它我们可以做什么。GStreamer包含了一些实用程序,尤其是:
- gst-inspect-1.0将允许你查看可用编解码器和模块的列表,因此你可以立即查看将对其执行的操作并选择一组过滤器和编解码器
- gst-launch-1.0允许你启动任何管道
GStreamer使用一种解码方案,其中数据流从源到接收器依次输出通过不同的组件。你可以选择任何东西作为源:文件、设备、输出(接收器)也可以是文件、屏幕、网络输出和协议(如RTP)
播放mp4文件的经典示例:
1 |
gst-launch-1.0 filesrc location=file.mp4 ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! autovideosink |
输入接受mp4文件,该文件首先经过mp4 demuxer – qtdemux,然后通过h264解析器,而后经过解码器和转换器,最后是输出。
你可以使用带有file参数的filesink替换autovideosink并将解码后的流直接输出到该文件。
使用GStreamer C / C ++ API编写应用程序。让我们尝试解码
当知晓如何使用gst-launch-1.0时,我们将在应用程序中执行相同的操作。原理保持不变:我们正在构建解码管道,但现在正使用GStreamer库和glib-events。
我们将考虑一个H264解码的实时示例。
对GStreamer应用程序进行一次初始化
1 |
gst_init (NULL, NULL); |
如果想要详细了解发生了什么,你可以在初始化之前设置日志记录级别。
1 2 |
gst_debug_set_active(TRUE); gst_debug_set_default_threshold(GST_LEVEL_LOG); |
注意:无论你的应用程序中有多少个管道,初始化一次gst_init就足够了。
让我们创建一个新的事件循环来处理事件:
1 2 |
GMainLoop *loop; loop = g_main_loop_new (NULL, FALSE); |
现在我们可以开始构建管道了:将必要的元素(特别是管道本身)命名为GstElement类型。
1 2 3 4 5 6 7 8 9 |
GstElement *pipeline, *source, *demuxer, *parser, *decoder, *conv, *sink; pipeline = gst_pipeline_new ("video-decoder"); source = gst_element_factory_make ("filesrc", "file-source"); demuxer = gst_element_factory_make ("qtdemux", "h264-demuxer"); parser = gst_element_factory_make ("h264parse", "h264-parser"); decoder = gst_element_factory_make ("avdec_h264", "h264-decoder"); conv = gst_element_factory_make ("videoconvert", "converter"); sink = gst_element_factory_make ("appsink", "video-output"); |
管道的每个元素都是通过gst_element_factory_make创建的,其中第一个参数是类型,第二个参数是其GStreamer的条件名称,稍后它将依赖于这个名称(例如,在发出错误时)。
检查是否找到了所有组件,否则gst_element_factory_make返回NULL。
1 2 3 4 |
if (!pipeline || !source || !demuxer || !parser || !decoder || !conv || !sink) { // one element is not initialized - stop return; } |
我们通过g_object_set设置相同的位置参数:
1 |
g_object_set (G_OBJECT (source), "location", argv[1], NULL); |
其他元素中的参数可以用相同的方式设置。
现在我们需要GStreamer消息处理程序,让我们创建相应的bus_call:
1 2 3 4 5 6 |
GstBus *bus; guint bus_watch_id; bus = gst_pipeline_get_bus (GST_PIPELINE (pipeline)); bus_watch_id = gst_bus_add_watch (bus, bus_call, loop); gst_object_unref (bus); |
需要gst_object_unref和其他类似调用才能清除选定的对象。
然后,我们将命名消息处理程序本身:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
static gboolean bus_call (GstBus *bus, GstMessage *msg, gpointer data) { GMainLoop *loop = (GMainLoop *) data; switch (GST_MESSAGE_TYPE (msg)) { case GST_MESSAGE_EOS: LOGI ("End of stream\n"); g_main_loop_quit (loop); break; case GST_MESSAGE_ERROR: { gchar *debug; GError *error; gst_message_parse_error (msg, &error, &debug); g_free (debug); LOGE ("Error: %s\n", error->message); g_error_free (error); g_main_loop_quit (loop); break; } default: break; } return TRUE; } |
现在最重要的是:我们将所有创建的元素收集并添加到单个管道中,该管道是通过gst-launch构建。当然,添加顺序很重要:
1 2 |
gst_bin_add_many (GST_BIN (pipeline), source, demuxer, parser, decoder, conv, sink, NULL); gst_element_link_many (source, demuxer, parser, decoder, conv, sink, NULL); |
我们还应该注意,这种元素链接对于流输出非常有效,但在回放(autovideosink)的情况下,需要额外的同步demuxer(视音频分离器)和解析器的动态链接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
gst_element_link (source, demuxer); gst_element_link_many (parser, decoder, conv, sink, NULL); g_signal_connect (demuxer, "pad-added", G_CALLBACK (on_pad_added), parser); static void on_pad_added (GstElement *element, GstPad *pad, gpointer data) { GstPad *sinkpad; GstElement *decoder = (GstElement *) data; /* We can now link this pad with the sink pad */ g_print ("Dynamic pad created, linking demuxer/decoder\n"); sinkpad = gst_element_get_static_pad (decoder, "sink"); gst_pad_link (pad, sinkpad); gst_object_unref (sinkpad); } |
最后,让我们将传送状态转换为回放:与静态连接相比,动态连接可以确定线程的类型和数量,并且在需要时可以在某些情况下工作。
1 |
gst_element_set_state (pipeline, GST_STATE_PLAYING); |
让我们运行事件循环:
1 |
g_main_loop_run (loop); |
完成此过程后,需要清理所有内容:
1 2 3 4 |
gst_element_set_state (pipeline, GST_STATE_NULL); gst_object_unref (GST_OBJECT (pipeline)); g_source_remove (bus_watch_id); g_main_loop_unref (loop); |
选择编码器和解码器。Fallbacks(回退函数)。
在文档中还有很多有用的但几乎未提及的内容:如何轻松组织回退解码器或编码器。
gst_element_factory_find 函数将通过检查元素工厂中是否有编解码器来帮助我们完成此任务:
1 2 3 4 |
if(gst_element_factory_find("omxh264dec")) decoder = gst_element_factory_make ("omxh264dec", "h264-decoder"); else decoder = gst_element_factory_make ("avdec_h264", "h264-decoder"); |
在此示例中,我们优先选择了RDK平台上的OMX硬件解码器,如果没有,我们将选择软件实现。
另一个非常有用但很少使用的功能是检查我们在GstElement中实际初始化的内容(许多编解码器中的哪一个):
1 |
gst_plugin_feature_get_name(gst_element_get_factory(encoder)) |
你可以通过一种简单的方法来完成此操作,然后返回初始化的编解码器的名称。
视频色彩模型
鉴于我们正谈论对来自摄像机的视频进行编码,所以颜色模型成为了绕不开的话题。这便是YUV(一种颜色编码方法)出现在舞台上的时候(比RGB频繁得多)。
相机只是喜欢YUYV色彩模型。但是GStreamer更喜欢与普通的I420机型协同工作。如果不是要在gl帧中输出,我们还将拥有I420帧。准备好设置你需要的过滤器并执行转换。
一些编码器也可以与其他颜色模型一起使用,但是更多情况下,这些是该规则的例外。
我们还应该注意,GStreamer有其自己的模块,用于从你的摄像机接收视频流,它可以用于构建管道,但我们将在其他时间再讨论它。
让我们处理缓冲区并即时获取数据。
输入缓冲区
截至目前,我们仅通过filesrc对该文件中的内容进行了编码,并将所有内容显示在同一fileink或屏幕上。因此是时候处理数据流了。
现在,我们将使用缓冲区以及appsrc / appsink的输入和输出。由于某种原因,官方文档中几乎没有考虑到此问题。
那么,如何在创建的管道中组织恒定的数据流,或者确切地说,如何获得一个编码或解码的输出缓冲区呢?
假设我们从相机中获取了图像,且需对其进行编码。我们已经决定需要一个I420格式的框架。假设我们拥有它,下一步是什么?如何在整个管道流程中传递图片?
首先,让我们设置need-data事件处理程序,它将在需要向管道提供数据并开始向输入缓冲区提供数据时启动:
1 |
g_signal_connect (source, "need-data", G_CALLBACK (encoder_cb_need_data), NULL); |
处理程序本身具有以下形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
encoder_cb_need_data (GstElement *appsrc, guint unused_size, gpointer user_data) { GstBuffer *buffer; GstFlowReturn ret; GstMapInfo map; int size; uint8_t* image; // get image buffer = gst_buffer_new_allocate (NULL, size, NULL); gst_buffer_map (buffer, &map, GST_MAP_WRITE); memcpy((guchar *)map.data, image, gst_buffer_get_size( buffer ) ); gst_buffer_unmap(buffer, &map); g_signal_emit_by_name (appsrc, "push-buffer", buffer, &ret); gst_buffer_unref(buffer); } |
你可能会说“图像”是I420中图像缓冲区的伪代码。
接下来,我们通过gst_buffer_new_allocate创建一个所需大小的缓冲区,它将对应于图像缓冲区的大小。
使用gst_buffer_map,我们将缓冲区设置为写入模式并使用memcpy将映像复制到创建的缓冲区。
最后,我们向GStream发出信号,表示缓冲区已准备就绪。
注意:写入后必须使用gst_buffer_unmap,并在使用gst_buffer_unref之后清除缓冲区。否则,将发生内存泄漏。在可用示例数量很少的情况下,没有人特别关注内存使用情况,尽管这非常重要。
当我们处理完处理程序后,要做的另一件事是在接收到预期格式的接收时配置上限。
这是在安装需求数据信号处理程序之前完成的:
1 2 3 4 5 6 7 8 9 10 11 12 |
g_object_set (G_OBJECT (source), "stream-type", 0, "format", GST_FORMAT_TIME, NULL); g_object_set (G_OBJECT (source), "caps", gst_caps_new_simple ("video/x-raw", "format", G_TYPE_STRING, "I420", "width", G_TYPE_INT, 640, "height", G_TYPE_INT, 480, "framerate", GST_TYPE_FRACTION, 30, 1, NULL), NULL); |
像所有GstElement参数一样,这些参数是通过g_object_set设置的。
在这种情况下,我们定义了流类型及其上限—数据格式,并指定appsrc输出将接收640×480分辨率和每秒30帧的I420数据。
对我们而言,频率通常不起作用。在工作时,我们没有注意到GStreamer以某种方式通过频率限制了需求数据的调用。
完成后,现在我们的帧被送入编码器。
输出缓冲区
现在,让我们了解如何获取编码的输出流。
我们将处理程序连接到接收垫(sink pad):
1 2 3 |
GstPad *pad = gst_element_get_static_pad (sink, "sink"); gst_pad_add_probe (pad, GST_PAD_PROBE_TYPE_BUFFER, encoder_cb_have_data, NULL, NULL); gst_object_unref (pad); |
同样,我们连接到另一个接收器事件GST_PAD_PROBE_TYPE_BUFFER,它将在数据缓冲区进入接收垫时触发。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
static GstPadProbeReturn encoder_cb_have_data (GstPad * pad, GstPadProbeInfo * info, gpointer user_data) { GstBuffer *buf = gst_pad_probe_info_get_buffer (info); GstMemory *bufMem = gst_buffer_get_memory(buf, 0); GstMapInfo bufInfo; gst_memory_map(bufMem, &bufInfo, GST_MAP_READ); // bufInfo.data, bufInfo.size gst_memory_unmap(bufMem, &bufInfo); return GST_PAD_PROBE_OK; } |
回调函数具有类似的结构。现在,我们需要到达缓冲存储器。
首先,我们获取GstBuffer;然后,通过索引0使用gst_buffer_get_memory获取其内存的指针(通常,它是唯一涉及的指针);最后,使用gst_memory_map获得数据缓冲区地址bufInfo.data及其大小bufInfo.size。
事实上,我们已经实现了获得具有编码数据及其大小的缓冲区的目标。因此,我们回顾了用于智能电视和机顶盒的类似Zoom的视频会议应用程序的关键和最令人兴奋的组件:架构、带有GStreamer的编码/解码模块、输入/输出缓冲区以及所使用的颜色转换。
对数字电视运营商而言,这样的软件平台可以成为新的订户服务。对工程师来说,这是一个用于实现基于RDK、Linux和Android的不同机顶盒的有趣新嵌入式项目。对其他人来说,这是一个可以在COVID-19隔离或远程办公期间花时间在一起看电影和体育比赛、做运动以及与亲人见面的机会。
无论是在工程解决方案还是业务场景方面,这种在智能电视上使用视频会议服务的想法都可以得到进一步发展。因此,请随时在下面的评论中分享你的想法。

文章翻译者:Nicholas,技术支持工程师、瑞科慧联(RAK)高级工程师,深耕嵌入式开发技术、物联网行业多年,拥有丰富的行业经验和新颖独到的眼光!