背景#
2019 年,一代致迅影眸图传发布。自媒体增长迅猛的时代,相机的各种附件也井喷式涌现,三轴稳定器、图传、麦克风… 大大小小厂商都在搞,自然不乏许多催熟而来的粗糙产品。影眸图传作为致迅的第一代图传产品,确实能看出一些小作坊气息,不过更多的则是好的方面,设计并不突出但很实在,而且使用体验做得非常不错:延迟够低可以看着图传跟焦,不外接供电也能使用 3-4 小时。结合发售时 799 元的售价和后期不到 300 元的二手价格,至今仍不失实用性。
从图传方案上讲,影眸属于 Wi-Fi 图传,并且没有配备专门的接收器。相机等 HDMI 信号源接入图传发射器,接收需要使用移动设备 Wi-Fi 连接图传后,通过配套 App 远程查看图像。采用 Wi-Fi 是低价图传的合适方案,通用的 Wi-Fi 网卡相比专用的无线方案,开发难度和成本都低很多。除 Wi-Fi 外,要廉价地实现满足 1080P 视频流传输的 5Mbps 及以上速率,方案就很少了。对于追求低价的图传来说,不配套专门接收器意味着节省了几乎一半的硬件成本,还可以利用手机、平板的现有屏幕,无需额外购买显示设备,降低了购买和使用门槛,使得影眸非常适合个人和小团队使用,代价是失去了连接大监和导播台的机会。
研究影眸,起初是想了解它如何实现一个体验不错的 Wi-Fi 图传。在能够正确解析影眸发送的数据流之后,又开始试图为它 “定制” 接收端,尝试解决它没有接收端而无法连接大屏或用于直播的问题,实现了基于全志 D1 的接收器方案和 OBS Studio 插件接收两种方案的原型。研究过程中发现选择影眸下手也算是挑中了软柿子:海思的硬件方案、Wi-Fi 的传输方式、简陋的初版接收 App,可用手段非常丰富,正好可以学习练手,下文便是对整个过程的记录。
硬件#
拆下正面的螺丝后即可打开影眸外壳,看到硬件方案。以下是主要芯片和基本信息:
- 主芯片:海思 Hi3516ARBCV100 SoC
- 接口芯片:联阳 IT6801FN HDMI - BT.1120
- RAM:海力士 H5TC2G63GFR DDR3L
- Flash:旺宏 MX25L12835F 16MB SPI Flash
- 网卡:欧飞信 8121N-UH 模组,高通 Atheros AR1021X 芯片,2x2 802.11 a/n 5G,USB 2.0 接口
- MCU:意法 STM32F030C8T6
主芯片海思 Hi3516A,处理器为单核 Cortex-A7,官方提供基于 Linux 3.4 内核的 SDK,重点是具备 H.264/H.265 视频硬件编解码器。官方定位为 “集成新一代 ISP 的专业 HD IP 摄像头 SoC”,在监控领域确实被广泛使用,并且一下揭露了低成本图传的本质 —— 在监控方案的基础上,把图像传感器输入换成 HDMI 输入,也就成了图传。监控方案出货量非常大,这款 SoC 也去掉了监控不需要的显示等外设,成本可以做得很低。稍微有些特殊的就是如何将 HDMI 输入转换为 SoC 上广泛具备的 MIPI CSI、BT.1120 等接口,但至少已有现成的 IC 可以选用。类似思路的产品还有这类 HDMI 编码器,同样利用海思方案。相比需要外置 Wi-Fi 网卡的 Wi-Fi 图传,它们可以利用 SoC 内置的 GMAC,只需外接一个以太网 PHY 芯片即可实现有线网络连接,能够采集 HDMI 并稳定地进行直播推流。
当初影眸的硬件方案让我最感震撼的还是仅有 16MB 的 Flash:一个运行 Linux 的设备,仅仅需要 16MB 的空间。不过冷静分析,很多运行 OpenWRT 的路由器也只需要 16MB 甚至 4MB 的 Flash,视频处理对空间的需求主要在 RAM 上。只要愿意舍弃 Debian 等发行版丰富的软件包并到处裁剪,为专门任务工作的 Linux 也可以占用很小的空间。
板端环境#
引导#
采用海思芯片,那么基本上会根据官方 SDK 开发。从板子上明示串口 R、T、G 的三个焊点上接出线来,上电之后便出现了 U-Boot 和 HiLinux 的引导 Log,果然是纯正的海思。
在 U-Boot 下执行 printenv
可以获取到启动内核的命令和传给内核的参数。从输出可以得知 SPI Flash 的布局是 1M(boot),3M(kernel),12M(rootfs),rootfs 是 12MB 的 jffs2 文件系统。引导过程为首先通过 SPI Flash(sf)设备探测(probe)获取 Flash 的信息,然后从 Flash 中偏移 0x100000(1MB)读取 0x300000(3MB)大小的内核到内存地址 0x82000000 处,最后使用 bootm
命令从内存启动内核。
bootfile="uImage"
bootcmd=sf probe 0;sf read 0x82000000 0x100000 0x300000;bootm 0x82000000
bootargs=mem=128M console=ttyAMA0,115200 root=/dev/mtdblock2 rootfstype=jffs2 mtdparts=hi_sfc:1M(boot),3M(kernel),12M(rootfs
在系统启动后#
进入系统后,如何找到板端运行的图传程序呢?根据海思的开发环境用户指南文档,系统启动后需要自动运行的程序可以添加进 /etc/init.d/rcS
。因此,打开 /etc/init.d/rcS
查看。
在 rcS
中主要内容如下:
首先修改了内核网络缓冲区,写缓冲区设置为 0x200000(2MB),读缓冲区设置为 0x80000(512KB):
#sys conf
sysctl -w net.core.wmem_max=2097152
sysctl -w net.core.wmem_default=2097152
sysctl -w net.core.rmem_max=524288
sysctl -w net.core.rmem_default=524288
加载无线网卡驱动
insmod /ko/wifi/ath6kl/compat.ko
insmod /ko/wifi/ath6kl/cfg80211.ko
insmod /ko/wifi/ath6kl/ath6kl_usb.ko reg_domain=0x8349
ip/dhcp 配置
ifconfig wlan0 10.0.0.1 netmask 255.255.255.0 up
echo udhcpd
udhcpd /etc/wifi/udhcpd.conf &
#echo hostapd
#hostapd /etc/wifi/hostap.conf &
加载 MPP 驱动,与 SDK 文档一致
cd /ko
./load3516a -i -sensor bt1120 -osmem 128 -online
加载 MPP 时主要进行初始化,加载了许多内核模块,输出 log 如下
Hisilicon Media Memory Zone Manager
Module himedia: init ok
hi3516a_base: module license 'Proprietary' taints kernel.
Disabling lock debugging due to kernel taint
load sys.ko for Hi3516A...OK!
load tde.ko ...OK!
load region.ko ....OK!
load vgs.ko for Hi3516A...OK!
ISP Mod init!
load viu.ko for Hi3516A...OK!
load vpss.ko ....OK!
load vou.ko ....OK!
load hifb.ko OK!
load rc.ko for Hi3516A...OK!
load venc.ko for Hi3516A...OK!
load chnl.ko for Hi3516A...OK!
load h264e.ko for Hi3516A...OK!
load h265e.ko for Hi3516A...OK!
load jpege.ko for Hi3516A...OK!
load vda.ko ....OK!
load ive.ko for Hi3516A...OK!
==== Your input Sensor type is bt1120 ====
acodec inited!
insert audio
==== Your input Sensor type is bt1120 ====
mipi_init
init phy power successful!
load hi_mipi driver successful!
在此之后,便会运行一个名为 RtMonitor
的程序,所有图传业务逻辑均在其中实现。
对板端环境的探索可以说过于顺利了,并没有出现任何的阻拦,甚至脚本中还有一些调试时留下的注释信息。实际上,海思的 SDK 文档中提供了各个环节加密的手段,如关闭串口、设置 root 账户密码等,若是应用上任何一种都会制造不小的麻烦。
传输#
抓包#
首先尝试通过 Wi-Fi 抓包来看看传输的都是什么。由于 ARM 架构的 Mac 上可以安装 iOS 的 App,运行 Accsoon App 后,打开 Wireshark 就能开始抓包了。可以发现有三种包:
- 图传→接收端 UDP:数据量大,推测是图传数据流;
- 接收端→图传 UDP:很短,推测为数据应答包;
- 接收端→图传 TCP:打开图传界面时发送,触发上述 UDP 传输,之后大约每 0.5-1 秒一个包,推测为心跳保活,内容有 "ACCSOON" 字样。
顺带进行一下监听模式抓包。可以发现当有多台设备连接时,由于 Wi-Fi 没有高速的组播 / 广播机制,需要将数据分别发送给每个设备,成倍地增加了信道压力。
Wi-Fi 图传的另一个劣势在于,如果遵守 802.11 协议,没有修改帧间间隔(interframe space)以及退避(backoff)随机数等使自身在信道竞争中取得不正当优势,那么这个图传相比其他 Wi-Fi 设备并没有更高的传输优先级。当信道上其他 Wi-Fi 设备大量活跃时,便会不可避免地导致图传卡顿。不过好在 5GHz 信道的拥挤程度一般还是好于 2.4GHz。
反编译安卓 Apk#
通过抓包还是难以看清数据包的具体内容,尤其是头部各字段的含义。于是尝试分析致迅安卓 App 内的逻辑。由于更新的版本为支持其他设备新加入了更多代码,还是老版本更利于分析。从 apkpure 下载支持影眸图传的较老版本(Accsoon 1.2.5 安卓版 APK)。使用 Jadx 对 apk 进行反编译,主要寻找以下内容:
- UDP 视频流数据包组成,用于正确解析视频流;
- TCP 控制指令内容和发送逻辑,用于正确触发设备开始发送功能。
对 Java 代码关键逻辑的分析:
- MediaCodecUtil 类
-
封装了对 Android 原生编解码接口 MediaCodec 的操作。
-
构造函数中对 MediaCodec 初始化,通过初始化时的参数可以得知,所用解码器为 "video/avc",意味着传输的视频流是 H.264 编码。
-
初始化 MediaCodec 时,
MediaCodec.configure
方法中传入一个Surface
,MediaCodec
将解码后的视频帧直接输出到该Surface
的BufferQueue
并回调onFrameAvailable()
。 -
putDataToInputBuffer
方法,与 MediaCodec 的输入缓冲区对应。会向缓冲区队列申请空缓冲区,将需要解码的数据拷贝进去,然后放入输入缓冲区队列。 -
renderOutputBuffer
方法,与 MediaCodec 的输出缓冲区对应。会从输出缓冲区队列获取已解码的数据,然后释放该缓冲区。
-
- MediaServerClass 类
Start()
方法,调用MediaRtms.Start()
和TcpLinkClass.StartMediaStream()
,分别启动 UDP 和 TCP。H264FrameReceiveHandle
作为回调函数在MediaRtms
实例化时传入。当H264FrameReceiveHandle
被调用时,会最终调用到MediaCodecUtil
中的putDataToInputBuffer
和renderOutputBuffer
。
- MediaRtms 类
- 对
rtmsBase
类的简单封装。 Start()
方法,会创建一个DatagramSocket
,并启动一个udpRxThread
线程。在该线程中,不停接收数据,收到一定长度数据后,解析包头,如果是视频则调用H264FrameReceiveHandle
回调。
- 对
- TcpLinkClass 类
- 调用
StartMediaStream()
后会启动一个KeepAliveThread
线程。在该线程中,以 1 秒间隔调用TcpLinkClass
类中的一个名为StaOp
的方法,里面实现了 TCP 连接、发送心跳包、断开连接的过程。
- 调用
- SurfaceRender 类
- 视频显示在
GLSurfaceView
控件上。在VideoMainActivity
中,调用setRenderer
方法,将SurfaceRender
设置为GLSurfaceView
的渲染器。 onSurfaceCreated
方法,创建一个绑定到 OpenGL 纹理(mTextureId
)的SurfaceTexture
(mSurfaceTexture
),用来接收MediaCodec
解码后的视频帧。并创建离屏渲染所需的帧缓冲对象(FrameBuffer)和纹理(Texture),为效果处理做准备。onDrawFrame
方法,绘制当前帧。调用updateTexImage
方法将SurfaceTexture
中最新的图像帧更新到绑定的 OpenGL 纹理。此时切换到离屏渲染,利用着色器程序将视频帧纹理和 LUT 纹理叠加实现 3D LUT 应用;切换回正常渲染,基于离屏渲染得到的纹理,通过着色器程序实现斑马线、黑白等效果,并显示;中心线、比例框等叠加元素最后单独绘制。
- 视频显示在
具体看一下数据包头有多长,有什么信息。
TCP Frame:
UDP Frame:
每个 Message 包含一帧的码流,每个 Message 前有一个 Message Header:
每个 Message 被分成若干段 Frame 发送,每个 Frame 前有一个 Frame Header:
H.264 码流提取#
知道了数据包的结构,便可以开始解析。根据 Frame 分段重组 Message 后,发现 Message 的内容以 0x000001
的固定前缀开头,具有 NALU(Network Abstraction Layer Unit)的特征,包含一个一字节的 NALU Header,重点是 nal_unit_type
,用于判断 Payload 中的内容类型。理论上到这里,只需要把 Message 的内容逐一送进解码器就可以解码视频流了。不过需要注意的是,解码器需要依靠 SPS 和 PPS 中保存的 profile、level、宽高、deblock 滤波器等参数才能正确解码,需要在 I 帧前告诉解码器。因此代码中最好能根据 nal_unit_type
进行判断,等待 SPS 和 PPS,使它们最先被送入解码器。
NAL Header:
NALU Type:
接收端设计#
接收方案 1 - 电脑接收#
数据包结构了然之后,只要正确接收数据包,取出其中的 H.264 码流送入解码器即可。为便于高效地开发和调试,先在电脑上进行。利用 FFmpeg(libav)或 GStreamer(libgst)等多媒体框架,可以方便地实现解码。首先尝试使用 FFmpeg,主要需要经过以下过程:
- 解码器初始化:使用
avcodec_find_decoder()
查找 H.264 解码器,使用avcodec_alloc_context3()
创建上下文,使用avcodec_open2()
打开解码器。 - 数据解码:使用
av_packet_from_data()
将数据存储到AVPacket
中,然后使用avcodec_send_packet()
送入解码器,使用avcodec_receive_frame()
从AVFrame
中取出已解码的数据。
整个程序大致逻辑为:
- 主线程:初始化 FFmpeg 解码器和 SDL 显示,启动 UDP 和 TCP 线程。随后循环等待可用数据的信号量,将数据解码并显示。
- UDP 线程:接收包,收集各
msg_id
对应的全部片段,组合完整后内容放入共享内存,信号量通知主线程。 - TCP 线程:定时发送心跳包。
连接图传的 Wi-Fi,运行软件。图传连接 RX0 小相机,用相机拍摄手机秒表,进行粗略的时延测试,左侧显示画面经过了手机屏幕显示→ RX0 拍摄屏幕并从 HDMI 输出→ HDMI 输入图传→ 电脑无线接收并从显示器显示。端到端时延基本在 200ms 左右。
接收方案 2 - 开发板#
有了在电脑上运行的程序,那么把它搬上嵌入式硬件也有了希望。我曾经屯了一块全志 D1 的芒果派 MQ-Pro D1,有 HDMI 输出,有 H.264 硬件解码器,还开放了完整的 SDK 和文档,能够满足制作接收端的大部分需求。美中不足的是 Wi-Fi 网卡只支持 2.4GHz,更换支持 5GHz 频段的 RTL8821CS 网卡并编译配套驱动后,才能连接影眸的热点。
全志为 D1 提供了 Tina Linux SDK。Tina 的亮点是基于 Linux 内核 + OpenWRT 构建系统,可以使智能音箱为首的 AIoT 产品更加轻量,毕竟 OpenWRT 更广为人知的用途是内存和存储同样非常有限的路由器。宣传中称原来需要 1GB DDR + 8GB eMMC 才能支撑的系统,使用 Tina Linux 系统只需要 64MB DDR + 128MB NAND Flash 即可。
D1 芯片有 H.264 的硬件解码器,而 Tina 系统支持了 libcedar 的 OpenMAX 接口,使得 GStreamer 可以用 omxh264dec
插件调用 libcedar 进行视频硬解码,再加上 Tina 提供了 sunxifbsink
插件,可以调用 DE 实现 YV12 → RGB。因此利用 GStreamer 进行解码和显示成了最佳选择。根据这篇文章配置好 SDK,排除各种编译问题后,得到了具备上述插件的 GStreamer,随后可以进行应用开发。
虽然在做方案一时没有想到方案二而采用了 FFmpeg,但是 TCP 控制指令和 UDP 数据获取部分都可以复用。使用 GStreamer 的核心在于由元素(element)依次构成一条管道(pipeline)。为了将从 UDP 获取的帧数据送入管道,可以使用 GStreamer 的 appsrc
。appsrc
提供了将数据送入 GStreamer 管道的 API。appsrc
有两种模式:push 模式和 pull 模式。在 pull 模式下,appsrc
会在需要数据时,通过指定接口从应用程序中获取相应数据。在 push 模式下,则由应用程序主动将数据推送到管道中。若采用 push 的方式,我们就可以在 UDP 接收线程里,主动地把数据 “发送” 到 appsrc
里。因此我们通过以下流程创建一个管道:
-
创建元素
appsrc = gst_element_factory_make("appsrc", "source"); parse = gst_element_factory_make("h264parse", "parse"); decoder = gst_element_factory_make("omxh264dec", "decoder"); sink = gst_element_factory_make("sunxifbsink", "videosink");
每个元素通过
g_object_set()
设置属性,其中caps
定义了数据流的格式和属性,以便元素正确处理,以及元素之间的协商。在这个应用中,appsrc
的caps
是最重要的,否则后续元素不知道收到的内容是什么格式。appsrc
的caps
配置如下:GstCaps *caps = gst_caps_new_simple("video/x-h264", "width", G_TYPE_INT, 1920, "height", G_TYPE_INT, 1080, "framerate", GST_TYPE_FRACTION, 30, 1, "alignment", G_TYPE_STRING, "nal", "stream-format", G_TYPE_STRING, "byte-stream", NULL); g_object_set(appsrc, "caps", caps, NULL);
-
创建管道并添加和链接元素
pipeline = gst_pipeline_new("test-pipeline"); gst_bin_add_many(GST_BIN(pipeline), appsrc, parse, decoder, sink, NULL); gst_element_link_many(appsrc, parse, decoder, sink, NULL)
这样就形成了一条
appsrc→h264parse→omxh264dec→sunxifbsink
的管道。
在 UDP 线程中,我们还是循环接收,收集各 msg_id
对应的全部片段,组合完整后内容放入 gst_buffer
,并通过 g_signal_emit_by_name(appsrc, "push-buffer", gst_buffer, &ret)
将缓冲区 gst_buffer
推送到 appsrc
中。在 gst_buffer
中,除了帧数据本身,dts
、pts
、duration
是需要传递的重要时间参数。将 appsrc
的 do-timestamp
属性设置为 TRUE
后,appsrc
会在接收到缓冲区时自动为其设置时间戳,但是对于 duration
(持续时间)必须根据帧率设置。如果不设置,实测发现会有难以描述的 “卡顿感”,究其原因可能是缺少 duration
设置导致播放速度不稳定。虽然可能引入额外的时延,但是为保证观感,还是设置为妙。
为了将完成的代码编译,可以编写一个 Makefile,使得我们的代码作为 OpenWRT 的一个软件包,在构建 rootfs 时一起被编译进去。
在板端运行软件,图传连接 RX0 小相机拍摄屏幕秒表进行粗略的时延测试,具体流程为左侧屏幕显示→ RX0 拍摄屏幕并从 HDMI 输出→ HDMI 输入图传→ 开发板无线接收并从 HDMI 输出→ HDMI 输入右侧显示器显示。端到端时延基本在 200-300ms 之间,并不低。好的方面是透过图传画面观看屏幕上播放的视频,观感还算流畅。
测试相机直接连接显示器,流程为左侧屏幕显示→ RX0 拍摄屏幕并从 HDMI 输出→ HDMI 输入右侧显示器显示,时延基本在 70ms 左右,因此图传本身时延基本在 130ms-230ms 之间。
借助这个接收端,可以实现通过 HDMI 连接大大小小的监视器,使影眸不局限于使用手机、平板监看。
接收方案 3 - OBS Studio 插件#
前两种接收方案使得使用影眸时可以通过电脑以及 HDMI 显示设备监看,但仍不能满足低延迟直播推流的需求。如果在方案一的基础上稍加改动接收程序,通过 localhost 的 UDP 将 H.264 码流发送给 OBS Studio,则会发现启用缓冲时流畅但延迟大,而不启用缓冲时延迟低但常有监看时没有的卡顿;方案二虽然可以连接采集卡采集 HDMI 输出,但会增加开发板上解码、输出和采集卡的延迟。要减少延时,直接开发 OBS 插件几乎是最好选择。
OBS Studio 支持通过插件扩展功能Plugins — OBS Studio 30.0.0 documentation (obsproject.com)。根据介绍,开发一个 Source 类型的插件便可将视频源接入 OBS。OBS Studio 的 Source 类插件开发中有同步视频源(Synchronous Video Source)和异步视频源(Asynchronous Video Source)。同步视频源如 Image Source 与 OBS 的渲染循环同步,由 OBS 主动调用视频源的渲染函数获取帧数据,适合图形绘制或特效处理;异步视频源可以运行在独立的工作线程中,与 OBS 的渲染循环异步,视频源主动推送帧数据到 OBS。对于网络流、摄像头输入,异步更加合适。
根据提供的插件模板 obs-plugintemplate 建立工程、准备环境,并参考 OBS 现有的 image_source 插件源码完成逻辑。要做的改动非常少,大部分代码均可以复用方案二的代码,不同之处在于解码得到的帧内容不能直接送给一个显示元素,需要通过 appsink
获取解码后的内容并调用 obs_source_output_video()
交给 OBS Studio。
编译成功后,将 build
目录下的 .so
文件复制到 OBS Studio 的插件目录(如 /usr/local/lib/obs-plugins/
),启动 OBS Studio 即可开始测试。同样进行时延测试,具体流程为左侧屏幕显示→ RX0 拍摄屏幕并从 HDMI 输出→ HDMI 输入图传→ 电脑 OBS 插件无线接收并显示。端到端时延基本在 200ms 左右,透过图传画面观看屏幕上播放的视频,观感也连贯流畅。
obs-studio 插件时延测试:左侧屏幕显示→ RX0 拍摄屏幕并从 HDMI 输出→ HDMI 输入图传→ 电脑 OBS 插件无线接收并显示
同时连接三种方案,可以感知到卡顿的增加,但是都仍保持了尚可接收的时延。
总结#
某种程度上来说,在强大的编解码器和成熟的无线技术的加持下,实现实时视频传输并不那么困难,软件逻辑可以非常简单直接。而海思带硬件编码器的芯片大量用于监控、5GHz Wi-Fi 的普及,又几乎无意中使得 Wi-Fi 无线图传这类产品能够以极低成本实现体验不错的实时视频传输。配合蓬勃发展的大屏设备使用,门槛进一步降低。可惜的是它们的上限相当受限:享受了 Wi-Fi 的生态,便要忍受 Wi-Fi 的拥挤;享受了成熟的硬件编解码器,也基本失去了改动的自由度。当然,这并不妨碍影眸本身是一个相当 “功能完备” 的产品,它能很好地完成既定任务,没有严重的短板,尽管有大量新产品问世,但依然能够有效地满足基本的图传需求。致迅曾在一场直播中介绍影眸的制造历程,他们能够早早抓准用户痛点、用心做出一款完成度颇高的产品,仍然令人佩服。