摘要:本文通过使用最新版的“NDI 6 SDK (Android)”,实现了在Android手机端显示监看局域网内NDI视频流的功能,并通过外接Android手柄(蓝牙或有线)与Android端NDI监看相结合,初步实现了手柄对局域网里设备的控制功能。
关键字:NDI 6,JNI,NDK,手柄控制器,大模型;
一、前言
距离上次写NDI开发的文章已经过去了四年,期间由于别的项目的原因,几乎没怎么碰Android开发和NDI,当时还是NDI 4 SDK的开发,现在已经到NDI 6了。利用NDI实现Android端的视频发送
开发的目的很简单,完成4年前的目标-Android NDI接收端的开发,有个影视机械臂特拍的开发项目,想通过NDI结合Android手柄,做机器人拍摄的实时控制,相信很多人也跟我有差不多的想法,并且都已经实现了。
本人开发能力有限,感谢这个时代,感谢所有免费提供大模型API的公司,豆包、通义千问、文心一言,让我的开发变的很顺利,也重新了解了之前一直不怎么熟悉的JNI,接下来我将从头开始介绍整个项目的搭建和编写过程。
二、NDI动态库的引入(加了很多自己的理解,已经懂的可以跳过)
2.1、NewTek官网去下载NDI 6 SDK(Android)
下载完成后解压,目录结构如下图,先看下说明书,了解下NDI 6一些新特性,以及Android端的一些改变,终于不用打开和管理“NsdManager”网络发现服务了!
2.2、新建一个Android C++的工程
并将NDI 6 SDK(Android)根目录下“Include”和“Lib”两个目录拷贝到Android C++工程文件的cpp文件目录里去,include目录下放的都是C++头文件,Lib目录下放的是对应平台的动态库“libndi.so”文件,头文件至关重要,建议大家根据自己的开发需求仔细去看一下,头文件里最终要的文件是“Processing.NDI.Lib”,该头文件其实是把NDI动态库的API暴露给用户了,并且包含了(include)跟自己在一个目录下的其他头文件,所有NDI的头文件在针对各类平台的API命名和调用方法几乎都一样,唯一不一样的只有编译方式。
在“Processing.NDI.Lib”头文件里,#pragma once表示只需要包含一次,不需要重复被包含,#ifdef表示检查宏定义是否被使用,如果被使用,则进去#ifdef~#endif里面的区域,ifdef __APPLE__,__cplusplus,__APPLE__是针对不同的编译平台,“Processing.NDI.Lib”头文件里的判定流程如下:
-》判定是否是静态库(PROCESSINGNDILIB_STATIC)-》不是
-》判断编译器是否是Win32平台(_WIN32)-》不是
-》判读是都是苹果平台(__APPLE__)-》不是-》定义库名(NDILIB_LIBRARY_NAME),对应的动态库文件是“libndi.so.6”
-》判断是都是C++编译-》不是-》定义库API(PROCESSINGNDILIB_API)的默认值
-》关联各种头文件,如Processing.NDI.structs.h,Processing.NDI.compat.h,Processing.NDI.Find.h,Processing.NDI.Recv.h等等,这些头文件里都暴露了一些API,同时也定义了一些数据结构和枚举
-》在有些define后面,跟着__attribute((visibility("default"))),它是GNU扩展属性,用于控制符号可见性(都可见),符号可见性决定了动态链接库(如.so文件)中的符号是否可以从其他模块访问(暴露出去了)
Processing.NDI.structs.h文件中的结构如下:
-》定义了NDI_LIB_FourCC,注意里面写的结构,此处书写结构了#define没用缩写结构,我理解的是NDI_LIB_FourCC应该是被重新定义了
-》定义了NDIlib_frame_type_e(帧内数据类型,视频1,音频2,元数据3,错误4)
-》定义了NDIlib_FourCC_video_type_e,即4位色彩空间类型(UYVA,UYVY等等)
-》定义了NDIlib_FourCC_audio_type_e,音频结构
-》定义NDIlib_frame_format_type_e,隔行或者逐行
-》定义了时间码和时间戳的最大数值NDIlib_send_timecode_synthesize,NDIlib_recv_timestamp_undefined
-》定义了NDIlib_source_t,网络中有效的NDI信号的数据结构
-》定义了对视频帧的描述信息的数据结构,NDIlib_video_frame_v2_t,如长宽,色彩空间,制式等
-》定义了音频、元数据、Tally等的描述信息的数据结构,NDIlib_audio_frame_v2_t,NDIlib_audio_frame_v3_t,NDIlib_metadata_frame_t,NDIlib_tally_t
Processing.NDI.compat.h文件中的结构如下:
-》将<stdbool.h>和<stdint.h>两个头文件包含进去了
-》定义了“INFINITE”的值,不清楚有啥用
2.3、NDI 6 SDK (Android)\Examples\C++目录下,一些例子参考说明
例子本身是用C++写的,而这次NDI Android端的开发,就是要通过JNI,将C++代码转换成java代码,因此参考官方给的例子是很重要的一步,本次我参考了NDIlib_Find和NDIlib_Recv两个示例,其实NDIlib_Recv就是高级版的NDIlib_Find,因此只要搞懂NDIlib_Recv这个示例就行了。
示例文件里,无一例外的都是先加载了“Processing.NDI.Lib”头文件,,然后再引用了动态库,C++里的动态库是dll形式的,而Android里的动态库是so形式了,Android的动态库如何关联,后面会介绍。
NDIlib_Recv.cpp的大致流程如下:(示例就是这么简单,跟NDI 4简直一毛一样)
-》NDI初始化,NDIlib_initialize(),这个初始化函数,是Processing.NDI.Lib.h头文件里开放的API所包含的
-》创建Find实例-》等待5秒,并获取信号源,这些和Find里面的操作是一样的
-》创建一个接收的实例,NDIlib_recv_create_v3(),开放的API,这个实例没有传入参数,但是在前面Find实例的时候,要确保发现的NDI数量(p_sources)要大于0才会创建这个接收的实例
-》将接收到的p_sources中的第一个信号源,连接到接收的实例(pNDI_recv),使用的是开放API函数NDIlib_recv_connect,第几个信号源自己可以选,示例里给了是第一个
-》毁掉Find实例,当传参给p_sources时,它已经没用了
-》这个实例里使用std::chrono这个时间命名空间,让视频运行5分钟,在这5分钟内,要让它显示出来
-》创建视频、音频帧的描述对象实例NDIlib_video_frame_v2_t,NDIlib_audio_frame_v2_t
-》使用NDIlib_recv_capture_v2函数(API暴露出来的),将接收实例中收到的信号,传递给video_frame和audio_frame,这个函数会返回一个FrameType,音视频是分包的
-》通过switch判定,当收到的包确定是视频包时(NDIlib_frame_type_video),打印视频的长宽,并释放掉(NDIlib_recv_free_video_v2)
-》过了5分钟,毁掉Recv实例,毁掉NDI库,程序释放,并结束
三、Android工程文件里的配置
3.1、回到之前建立的Android工程文件里,需要重新写一下“CmakeLists.txt”文件
将NDI的动态库文件关联进去,只要关联的,才能通过JNI转换,才能在编译的时候将它加进去,Cmake的指令说明如下图,仅供参考。
3.2、修改AndroidManifest.xml的设置
其实就是Activity界面加载时的一些权限和样式的说明,权限和样式的需求,可以在AndroidManifest.xml里改,也可以进了Activity里动态的修改,在xml里更方便简单,因此我都是在AndroidManifest.xml里修改的权限。
首先是网络的一些访问权限,只要是涉及到Internet的,都开了,其他权限根据自己项目需求适当打开,其次是显示页面的横竖显示,我这次开发是使用的横屏,为了更好的适配手柄,我把横屏又倒过来了。
最后是设置页面显示为全屏,推荐在“style.xml”里修改,有些项目里也叫“theme.xml”
四、Android上NDI Recv开发流程的实现
4.1、确定页面样式
本人使用的页面及控件都是Android自带的,以实现功能为主,美观为次要,页面里主要包含以下控件:
Spinner控件:下拉菜单,显示局域网里的NDI流,并将这些NDI流的名字放入进去,当下拉选择NDI流的时候,可以切换NDI源的显示;
ImageView控件:NDI视频流显示的页面,在所有控件的最下层;
TextView控件:打印手柄控制案件的一些信息,方便调试;
Button控件:定义了“机器人轨迹复现”的按钮,按下去后,将在UDP里发送对应的控制信息;
Switch控件:体感控制的开关,也是对应机器人控制那块的功能,后面有机会再单独写一篇,介绍六轴工业机器人体感控制的方法;
4.2、确定NDI流软件内接收及显示的流程
1.在Java端,onCreate内,对所有控件进行初始化,包括ImageView,TextView,Spinner,Switch,Button,Bitmap代码如下:
2.设置按钮(Button)和下拉框(Spinner)的监听事件,按钮的监听事件就是改变一个bool值的状态,下拉框的监听事件为了重新连接NDI源:
3.启用UDP服务,封装到一个方法里,名为“startUdp()”;
4.建立Thread线程“findNDI”,并局域网里发现的NDI信号源,放置到Spinner控件内:
5.建立NDI视频流捕获和显示的线程,该线程里有个“SetUpdateImg()”的方法,是一个Handler的委托,里面循环去捕获NDI视频流,并发送UDP数据:
6.在SetUpdateImag()方法里,做两件事,分别是不停的获取NDI视频流,以及开定时器每隔40ms在画布上显示,为什么不把两件事件并成一件事情去做,因为NDI在接收时解码再加传递给JAVA是一件比较耗时的工作,因此我把解码和传递单独再开了个线程运行。
"StartRecvCapture"这个方法就是C++里接收NDI信号,并传递给JAVA端的方法,“sendString”对应的是要发送的Udp数据,updateRunnable是为了每隔40ms将NDI信号显示到ImageView上。
7.updateRunnable方法如下,将StartRecvCapture返回的int数组,放入Bitmap里,再把Bitmap投影到ImageView上。
4.3、C++端软件的实现方法以及JNI接口的心得
不得不说发明JNI的人真的是太伟大了,此次涉及到JNI开发的主要有3个方法,分别是FindNDI(搜寻NDI流),CreateRecvNDI(选择NDI源),StartRecvCapture(接收NDI流),下面就这三个方法做详细介绍。
1.FindNDI方法:
JAVA端,输入参数无,返回参数String数组;
C++端,输入参数JNIEnv(JNI的环境,里面对应了各种JNI方法),jobject(调用这个方法的对象,此处是“MainActivity”,这就是面向对象编程的优势),返回参数jobjectArray,对应的是Java端的String数组。
JNI开发中,不管是C++端传入给JAVA端,还是JAVA端传递给C++端,都需要通过JNI来进行转换后传递,传递过程中数据格式的改变,方法的调用和获取,JNIEnv里提供了丰富了接口,在C++端我用的FindNDI方法,参考的是NDI SDK里FindNDI.cpp文件,稍做些修改就能用。
2.CreateRecvNDI方法:
JAVA端,输入参数int,返回参数bool值,int表示选第几个NDI信号源,返回bool表示切换信号是否成功;
C++端,输入参数JNIEnv,jobject(MainActivity),jint(对应JAVA端的int),返回参数jboolean(对应JAVA端的boolean);
之前findNDI的时候,已经获取了局域网里NDI的视频源信息以及“pNDI_recv”接收句柄,因此重新选择信号源就很方便,只要根据pNDI_recv重新选择对应的sources即可。
3.StartRecvCapture方法:
在JAVA端起循环,不断的去调用StartRecvCapture方法,方法返回一个排列好的Bitmap数组(ARGB),每隔40ms,ImageView将返回后的Bitmap数组进行显示。
JAVA端,输入参数无,返回参数int数组,int数组里存放的是ARGB的Bitmap数据;
C++端,输入参数JNIEnv,jobject(MainActivity),返回参数jintArray;C++端使用NDIlib_recv_capture_v2方法,传入之前NDI接收的句柄pNDI_recv,,等待返回数据类型是视频(NDIlib_frame_type_video)的数据过来;数据过来后,从p_data指针中获取视频帧,然后进行RGBA到ARGB的转换,最终返回一个jintArray;
4.个人对NDI 6 SDK Android端开发的理解:
NDI视频流在接收时会涉及到一些解码相关的设置,这些在NDIlib_recv_create_v3_t(创建pNDI_recv接收句柄时声明)这个开放的接口里都有解释,可以从“Processsing.NDI.Recv.h”这个头文件里看到其默认设置,此处的默认色彩控件时NDIlib_recv_color_format_UYVY_BGRA,,即没Alpha通道时是UYVY格式,有Alpha通道时是BGRA格式,在“NDIlib_recv_color_format_e”枚举里也能看到相同的解释(也在Processsing.NDI.Recv.h头文件里查找到)。
默认情况不带通道的色彩空间UYVY很难转换成能被Bitmap识别到的色彩空间,因此在声明NDIlib_recv_create_v3_t,,我稍加了修改,将里面的色彩空间设置成了“NDIlib_recv_color_format_RGBX_RGBA”,不管有没有Alpha通道,接收到的视频数据里,都包含RGB,能够很方便的被Bitmap识别到。
此处有心的小伙伴,可以去查看下NDI之前的开发手册,默认的色彩空间配置都是NDIlib_recv_color_format_RGBX_RGBA,,那为什么这次NDI 6里面修改了呢,我的理解是为了加快NDI接收端解码的速率,并且色彩空间的设置里,有一项NDIlib_recv_color_format_fastest的说明,也验证了我的想法。
通过NDIlib_recv_create_v3_t声明接收句柄,通过NDIlib_find_get_current_sources去发现网络里的NDI源,NDIlib_recv_connect根据这两个输入参数去连接NDI信号源,NDIlib_recv_capture_v2负责将连接好的信号源进行解码并获取到视频帧video_frame,从视频帧里我们也可以获取到跟视频有关的很多信息,如解码后存放的视频帧颜色空间FourCC,视频的长、宽、帧率、数据指针等等。
色彩空间FourCC依据之前视频解码时的句柄pNDI_recv配置而会对应不同的格式,如之前pNDI_recv设置了NDIlib_recv_color_format_UYVY_BGRA或者NDIlib_recv_color_format_fastest,则video_frame里对应的FourCC格式是NDIlib_FourCC_video_type_UYVY,,如果pNDI_recv设置了NDIlib_recv_color_format_RGBX_RGBA,,则FourCC格式是NDIlib_FourCC_video_type_RGBA或NDIlib_FourCC_video_type_RGBX,我的理解是,NDI在接收解码时,不做过多的干预,只是通过不同颜色空间数据的排列组合进行存放而已,原来发送过来的视频数据是什么编码方式,在NDI接收端就尽量还原成什么方式。
5.JNI开发中一些较常用到的方法:
env->NewStringUTF,将c++里的string字符串,转成jstring,传入的是char*指针;
env->NewObjectArray/NewInArray/NewByteArray等等,创建一个对应格式的JNI数组,并规定好对应的长度;
env->SetObjectArrayElement/SetIntArrayElement/SetByteArrayElement等等,往数组里传输参数,一般用于for循环遍历;
env->SetIntArrayRegion//SetByteArrayRegion等等,将整段数组中的数据进行传入;
env->DeleteLocalRef,释放本地引用;
C++端调取JAVA端函数的方法:获取JAVA中的对象,myClass=env->GetObjectClass(thiz);env->GetMethodID(),这是返回methodID的方法,输入参数里传入myClass,函数名,以及函数入参(例如“([BIII)V”,[B表示byte数组,I表示整数,V表示void,不用死记,AndroidStudio会自动帮你补全的);env->CallVoidMethod/CallBooleanMethod/CallByteMethod等等,根据之前的获得的methodID,调取JAVA里面的函数方法,传入相应的参数;
4.4、Android手柄控制器的输入和功能定义
此次开发手头正好有2套手柄,分别是小鸡X2S有线手柄和小鸡G8+射手座无线手柄,用的都是默认的Android输入功能,经过测试者两款手柄的输入方式是一样的,开发的Android程序内都兼容,其他品牌的手柄没做过测试,原理应该类似,都是作为Android设备的游戏手柄输入。
为什么这次开发中一定要将手柄的输入直接读到Android设备中,而不是通过蓝牙或者有线的方式进入PC端做开发?这个问题也困扰我很久,在实际应用中蓝牙的传输距离有限,Wifi稍微好一点,最好还是Lora天线的传输方式,为了方便开发和扩展,我取了个折中方案-WIFI控制。
Android手柄的控制是通过复写MotionEvent和KeyEvent两类事件得到的,其中MotionEvent针对的是摇杆和扳机键这种控制,键值是-1~1的浮点数,KeyEvent对应的是按钮的按下和弹起,键值是0和1的boolean值。
1、MotionEvent的使用:
在MainActivity里复写onGenericMotionEvent方法,处理手柄的移动事件,此次只针对左右摇杆,我把扳机键放在KeyEvent里用了,经过测试发现,MotionEvent.AXIS_X对应左摇杆的上下,MotionEvent.AXIS_Y对应左摇杆的左右,MotionEvent.AXIS_Z对应右摇杆的上下,MotionEvent.AXIS_RZ对应右摇杆的左右,范围都是-1~1;
获取到左右摇杆的轴的数值后,将其写入TextView中,方便观察调试。
2.KeyEvent的使用:
复写onKeyDown和onKeyUp事件,当对应的按键按下去的时候对应的键值为True,当按键弹起来的时候对应的键值为false,示例如下图所示,不同的手柄可能对应不同的键值,换手柄的时候注意先去测试按键对应的值(keyCode);
3、整个手柄按键列表对应如下:(轨道机器人和AGV复合机器人控制,适用于有线和无线连接的小鸡手柄,推荐有线方式,响应更快更稳定)
4.5、UDP服务端的数据发送和格式
UDP数据的发送,紧接视频帧接收(StartRecvCapture)的后面,理论上应该跟视频的帧率相同,例如如果视频是1080 50i,那么发送UDP数据的时间间隔是20ms,但是考虑到界面和JNI数据传递的延时,实际的UDP数据时间间隔应该是不好确定的。
UDP服务端的建立流程如下:
在MainActivity的onCreate时,调用“startUdp”方法,创建UDP接口,等待客户端连接,当客户端连接成功后,即可拿到客户端的IP地址和客户端本地端口号。
UDP客户端如果跟服务端建立连接(一般是随便发一组数据),在UDP服务端就可以获取到UDP客户端的IP地址和端口号,此时再将UDP控制数据全部发送过去,本文只针对了单个UDP客户端的连接方法,如果想用UDP服务端做广播发送的话,原理差不多,广播其实就是提前往广播地址先送信号,UDP客户端再从广播地址去获取信号。
UDP服务端向UDP客户端发送信号的方法,放在了接收NDI视频流数据(SetUpdateImg)的后面,如下图所示:
五、总结
5.1、遇到的一些坑
1、视频数据色彩空间转换问题
再NDI 6 SDK(Android)的开发过程中,默认视频接收端色彩空间的编码格式是“NDIlib_recv_color_format_UYVY_BGRA”,即在接收没有Alpha通道视频的情况下,色彩空间是UYVY(采样4:2:2),这种情况下p_data视频指针后对应的数据长度是xres*yres*2,每帧视频数据的排列方式是U0Y0V0Y1U1Y2V1Y3.....,即每4个Y(亮度)信号采样,对应2个U色度信号采样和2个V色度信号采样,每隔像素包含2个字节的长度。
如果要将UYVY的数据,转成Android能显示的数据,光靠Bitmap无法实现(Bitmap里不包含YUV格式,只有RGB格式),转换起来比较复杂,一般需要先转换成YUV的数据格式,而YUV的数据格式中还包含2种变体,第1种是平面格式(Y、U、V分别存储在不同的平面),第2种是半平面格式(Y一个平面,UV交错一个平面,常见半平面NV12和NV21,NDIlib_FourCC_type_P216表示4:2:2的NV12平面),NV12是U在前V在后,NV21是V在前U在后,转换起来涉及到的过程有点复杂,因此本此开发中最终没采用UYVY的采样方法,而选择了效率相对低一些的“NDIlib_recv_color_format_RGBX_RGBA”色彩空间,该色彩空间的优势是比较容易传递给Bitmap(ARGB888),劣势是接收的效率没有UYUV那么快。
说到此处,顺便再介绍一下Bitmap中的RGB_565和ARGB888两种色彩空间编码方式,在Android的Bitmap类中,RGB_565表示每个像素的信息存储在2个字节的信息中,R占用5bit,G占用6bit,B占用5bit,不带通道信息,使用short来存储16bit的像素信息,short color = (R & 0x1f) << 11 | (G & 0x3f) << 5 | (B & 0x1f);
ARGB888,表示每个像素占用4个字节,A占用8bit,R占用8bit,G占用8bit,B占用8bit,int color = (A & 0xff) << 24 | (B & 0xff) << 16 | (G & 0xff) << 8 | (R & 0xff)。
NDI接收端选择“NDIlib_recv_color_format_RGBX_RGBA”解码,Bitmap里选择ARGB888编码后,在C++里的转换方式如下,传递的数据是jintArray,其中每隔Int占用4个byte,分别用于存储A、R、G、B的数据。
2、JNI数据传递问题(传递准确性和传递效率)
开发初期曾尝试过将C++接收到的p_date指针对应的byte数据直接传递给JAVA端,结果显示到JAVA传递数据有误,仔细查看后发现p_data(视频帧数据)对应的格式是“uint_t”(8位无符号int,数据范围0~256),如果通过从C++端传递到JAVA端,一般是通过jbyte的方式进行传递,而jbyte是8为有符号int,数据范围-127~127,这就导致传递过去的数据如果超过127后,显示为负数,后期再将这些数据显示出来会比较麻烦且不直观。
与此同时,原本开发时的设想是将p_data(视频帧数据)先传递到JAVA端,然后再进行颜色格式的转换(RBGA转ARGB),最后再显示到ImageView上,实际操作中发现,如果将原始视频数据的传递、转换、显示都放到JAVA端的话,最终会导致显示的画面非常的卡顿,很多控件操作(Spinner、Button、Switch)将导致非常的卡顿,因此最终改成了先再C++端转换,然后传递(JAVA端单独的线程),最后显示(每隔40ms,独立的Handler处理)。
5.2、后续的优化建议
NDI解码效率,我不清楚为啥这次官方给的SDK里面,默认的解码方式是UYVY,查了下在Android端,对YUV数据的小时效率更高,并且也有相应的方法,但是总觉得没有Bitmap那么方便好用,后续优化的话,还是得在UYUV解码上下功夫,看看有没有办法,用最简单的方式,将UYUY的数据显示到Android端,提高整理的解码和显示效率。
此次官方给的默认SDK里,应该只是针对Full NDI的处理方式,应该还有Advance SDK(本人没测试过,网络问题也不太好拿的到),有针对NDI HX做相应的编解码方案,对于监看而言,NDI HX无疑是更优的解决方案。
此次开发在Bitmap显示上做了偷懒处理,默认分辨率就是1920x1080,并且处理的视频数据只针对ARGB的色彩空间,后续如果优化的话,需要增加更多的兼容性,例如4K HDR的支持,例如各种色彩空间的转换和显示等。