playgroud.tensorflowAI
发现了一个特别好的学习机器学习的可视化工具playgroud.tensorflow,通过它可以让你快速理解什么是线性回归,什么是逻辑回归,有人还写了一篇文章,讲解了如何使用这个工具playgroud.tensorflow工具的使用。
但有一些同学还不清楚如何用它来编译、调试C/C++程序,本文就向你介绍一下。
在我们开始配置C/C++开发环境之前,我们先来了解一下VSCode调试程序的基本步骤,这样有利于我们遇到调试问题可以及时的找到解决办法。
在开发调VSCode之前,我们先来看一下Linux下编译和调试C/C++程序的过程,具体如下:
实际上,VSCode也是按照这个步骤来的,只不过由于VSCodes可以编译调试多种程序,如C/C++、Java、JavaScript等,因此在使用它编译调试程序之前,你要先告诉它你要编译的是哪种程序,使用的编译器和调试工具是什么,为此我们需要为其编写几个配置文件。
第一个配置文件是launch.json
,该配置文件的作用是告诉VSCode使用的调试工具是什么,编译配置文件tasks.json
所在的位置等。第二个配置文件是tasks.json
,它的作用是指定编译器和编译参数。
另外,如果用户的调试工作没有特殊需求的话,VSCode还可以根据用户指定的编译器自动为其项目生成launch.json
,因此用户只需要编写tasks.json
文件即可。
接下来我们就来看看在VSCode中如何编写tasks.json
文件。
首先,需要为其添加一个C/C++扩展,具体步骤如下图所示:
第二步,添加编译、运行配置文件,如下图所示:
当执行上面步骤后,你就可以在VSCode左侧文件列表栏中的.vscode下发现tasks.json文件了。其内容如下:
1 | { |
在上面的配置中,有两个字段特别重要,一个是command另一个是args,其中command指明使用的编译器,在不同的平台下编译器是不同的,例如在Mac下使用的是clang,在Linux下使用的编译器是gcc,而在Windows下使用的是cl.exe。args为编译器指定输入参数,如-g 告诉编译器编出的程序是debug版本,-o告诉编译器编译出的文件名的名子。
此外我们还能通过args为编译器提供其它参数,如我们依赖的库和头文件等。举个列子,如果我们依赖ffmpeg库,则可以通过下面的方式将所依赖的库告诉编译器:
1 | ... |
其中,-I指定依赖库的头文件路径;-L指定依赖库所在的路径;-l指定具体的依赖库。
通过上面的配置一般情况下就可以正常调试程序了,但有的时候会出现些莫名其妙的问题,此时就需要我们手动执行一下编译任务了,具体操作如下:
当程序重新编译成功后,我们再次偿试Debug就可以成功了。
除了上面的编译环境需要配置外,使用VSCode编辑C/C++代码时还要配置编辑环境。主要的原因是引入外部库时,如果不在编辑环境中指定外部库的头文件路径,那它就会一直提示我们找不到头文件。
该如何配置C/C++编辑环境呢?最简单的做法是在我们添加新的头文件时,当VSCode报错时,我们来配置C/C++环境。具体步骤如下:
当执行完上面的操作后,就可以看到c_cpp_properties.json文件了,该文件的内容如下:
1 | { |
为了让VSCode编辑器可以正确找到需要的头文件,我们需要在c_cpp_properties.json的includePath字段中加入引入的头文件路径,下面是添加ffmpeg的头文件路径例子:
1 | ... |
至此,我们就将可以编辑、编译、运行C/C++程序的VSCode环境设置好了。
现在我们小结一下,要想使用VSCode编辑、编译C/C++程序我们需要做三件事儿,第一件事儿是安装C/C++插件;第二件事儿,配置编译环境,也就是配置tasks.json;第三件事儿是配置编辑环境,对应的配置文件为c_cpp_properties.json。当将这三件事儿做好后,我们就可以使用VSCode的愉快的编写C/C++代码了。
下图中是VSCode中一个内置的变量,有时候我们在各种配置文件中会用到这些变量,放在这里方便查阅
第一步,执行下面的操作创建vite项目:
1 | npm create vite |
创建项目时,我们可以选择不同的模板来创建不同的项目。这里我们选择 Vue,之后再选择TypeScript,这样我们就创建了一个使用TypeScript作为开发语言的Vue项目,而其打包和测试工具使用的是Vite。
如果你的系统中没有
npm
命令,则需要先安装nodejs,关于nodejs的安装请自行到网上查阅。
接下来,执行下面的操作:
1 | cd vite-project #进入到项目目录下 |
执行完上面的操作后,你就可以通过访问http://127.0.0.1:5173/地址,看到Demo程序的样子了。
现在我们已经Vue部分构建好了,接下来咱们构建一下Electron相关的部分。执行下面的命令,安装Electron:
1 | npm install electron --save-dev |
该命令中的--save-dev
指明安装的Electron仅用于开发环境。
Electron安装好后,在Vue项目目录下创建electron
子目录,并创建两个文件,一个是main.ts另一个是preload.ts。其中main.ts用于创建App窗口,而preload.ts用于预加载一些资源,一般情况下我们不需要预加载任何东西。这两个文件的代码实现如下:
1 | // main.ts |
1 | // preload.ts |
至此,我们就将Vue和Electron两部分的代码实现完成了。其中,Vue相关的代码都是由脚手架实现的,而Electron的代码是从官方Demo中拷贝来的。
实际上Electron是由浏览器Chromium改造而来的,因此你可以把它看作是一个浏览器,浏览器上页面展示的内容由HTML编写,样式由CSS控制,而逻辑由Javascript实现。
Electron既然是浏览器,那么这些规则它也要遵守,所以我们编写的TypeScript代码不能在浏览器上运行,同样也不能在Electron上运行。因此,我们需要使用tsc
编译器将main.ts
和preload.ts
编译成main.js
和preload.js
,这样它才能在Electron上正常工作。
当然每次都手动执行tsc
显然是不高效的,更好的办法是在启动Electron App时或者编译项目时自动执行该操作,这样更简单、更合理,为此我们需要修改一下相关的配置文件。
package.json该文件的作用是指明项目的依赖库及脚本命令。其内容如下:
1 | { |
在该配置文件中有两个字段需要我们重点关注一下,第一个是main字段,该字段指明了Electron的入口点;第二个字段scripts,该字段中包括好几个子字段,这些子字段指明有哪些npm run指令,比如当我们执行 npm run dev 时,它就会调用vite命令。同时,当我们执行npm run electron:dev时,它会执行tsc和 electron. 两条命令。
tsconfig.json,该文件是编译器tsc的配置文件,其作用是指定源文件位置及目标文件的输出位置。每当执行tsc命令时,tsc就会加载tsconfig.json文件,并从中读取配置项,tsconfig.json内容如下:
1 | { |
这个配置文件中也有两个我们需要特别注意的项,一个是outDir,指明目标文件输出到哪个目录下;另一个是include,指明源文件所在路径。
除了上面的两个重要的配置文件外,还有一个与vite相关的配置文件,即vite.config.ts。它的作用是告诉vite如何编译Vue程序,其实现如下:
1 | import { defineConfig } from 'vite'; |
在这个文件的defineConfig()函数中,指明了Vue的输出目录为dist/renderer,编译后的JavaScript格式为cjs,也就是Commond JS,并将electron相关的代码排除在外。
至此我就将项目中的几个配置文件及其作用向你介绍清楚了,接下来咱们来看看Electron的运行模式。
Electron有两种运行模式,一种是从Web服务器加载页面的工作方式,这种工作方式的好外是容易更新升级,当服务端的页面发生变化时,客户端重新启动后会立即更新,而其缺点是必须要有网络,加载速度相对较慢;第二种是从本地加载页面,其好处是加载速度快,缺点是升级困难。
一般情况下我们使用Electron开发的桌面App都采用第一种方案。 面我们来看一下如何加载在线页面。
其实让Electron加载在线页面非常容易,只需在main.ts中执行一行代码即可,如下:
1 | ... |
通过上面的代码就可以将URL地址中的index.html加载进Electron。
同时,想加载本地页面也很容易,只需将**loadRUL()函数修改为loadFile()**即可,如下:
1 | ... |
需要注意的是loadFile函数中的输入参数一定要与package.json中设置的输出路径一致,即从dist目录开始,直到index.html结束。
现在我们就可以编译并运行Electron App了,为此我们要执行下面的命令。
1 | npm run build #用于加载本地页面 |
1 | npm run electron:dev |
编写代码就绕不开调试,对于Electron App的调试有两种方式,一种是通过浏览器调试,对于Web前端开发人员来说,比较喜欢这种方式;另一种是使用VSCode进行调试。
下面我分别对这两种方式进行一个介绍
使用浏览器对Electron App调试又分为两种,一种是对Electron App的调试,另一种是对Electron App所显示的页面的调试,咱们一项项来说。
首先看如何对页面调试,实际上调试Electron App的页面与浏览器下调试页面是一样的,只不过打开调试工具的方式不同罢了。
对于Chrome浏览器我们可以通过鼠标右键->检查来打开调试器,而Electron App则不能这么做,正确的打开方式是通过Option+Command+I或Ctrl+Alt+I这个组合键打开调试器。
如果想调试Electron App,我们需要在启动Electron App时加上–inspect参数,即将它添加到package.json的electron:dev这条命令上,如下所示:
1 | ... |
inspect=1234中的1234指明开启的调试端口号是多少。有了这个端口号,我们就可以在Chome浏览器的输入框中输入chome://inspect,然后指定好端口号,就可以对Electron进行调试了。
我们同样可以使用VSCode对Electron App进行调试,只不过需要添加几个配置文件。
第一个,修改tsconfig.json文件,如下:
1 | { |
最重要的是要为ts代码生成.map文件,这样才能对TypeScript进行调试
之后,添加tasks.json文件,其内容如下:
1 | { |
该配置文件用于编译TypeScript代码,它在开启调试器是被调用。
第三个,添加launch.json文件,其内容如下:
1 | { |
当打开调试器时,调试器会读取该文件,并执行该文件中定义的preLaunchTask任务。该任务就是tasks.json中定义的任务。之后启动Electron App,并打开9229调试端口。
调试器通过9229向Electron App发调试指令,如我们在main.ts中设置了断点,调试器就会通过该端口向断点发送给Electron App,当程序运行时,断点就会停留在指定的行处。
当然Electron App执行的是main.js中的代码,它与main.ts之间的对应关系就是通过sourceMaps生成的.map文件找到的。
至此,我们就使用Vue3+Vite+Electron+TypeScript实现了一个最简单的Electron App。其功能非常简单,就是在Electron中将Vue的Demo页面显示出来。
这只是我们使用Vue3+Vite+Electron+TypeScript开发Electron App的第一步,后面有时间我会较弱输出一些更高阶的知识。
目前最流行的有Jupyter Notebook 和Jupyter Lab两种开发工具,其中Jupyter Lab 功能更强大,也是最近几年刚推出的,我们就介绍一下它的安装步骤。
第一步,我们需要先安装MiniConda包,关于MiniConda的作用、安装与使用请参阅这篇文章
之后执行下面命令,安装Jupyter Lab:
1 | conda install jupyterlab |
最后执行下面命令启动Jupyter Lab:
1 | jupyter lab --port 8080 |
执行完这条命令后,它就会打开浏览器,在浏览器中你可以创建自己的Notebook了。
注意,当安装好Jupyter Lab后,并不代表它已经将所有需要的包都安装好了。在运行JupyterLab 时有可能还会报错。不过没关系,我们只需要根据错误报告,将缺少的包安装上即可。
安装Jupyter NoteBook与安装Jupyter Lab是类似的,我们可以通过下面的命令安装它:
1 | conda install jupyternotebook |
启动Jupyter NoteBook的命令如下:
1 | jupyter notebook |
如果你想将已经写好的*.ipynb文件在Jupyter Lab 中打开可以按下面的步骤操作:
首先进入到你的.ipynb文件所在的目录,如下:
1 | cd xxx |
之后打开命令控制台,将Conda切换到你自己的环境下,命令如下:
1 | conda activate xxx |
最后启动Jupyter Lab:
1 | jupyter lab |
此时在弹出的Jupyter Lab界面中就可以看到你要导入的.ipynb文件了。
第一步,从这个地址下载实验的Jupyter项目,命令如下:
1 | git clone https://github.com/kaieye/2022-Machine-Learning-Specialization.git |
第二步,进入到项目目录中,命令如下:
1 | cd 2022-Machine-Learning-Specialization |
第三步,启动Jupyter Lab
1 | jupyter lab |
最后一步,进入到我们要做的实验项目目录下,然后进行work目录,这里边的.ipynb文件才是可以执行的(一定要注意这一步)。
]]>既然通过底层传输协议无法互通,那有没有其它方案呢?
当然是有的,其中最为成熟的一种方案是通过服务器中转实现两者的互通,架构如下所示:
上面的架构还可再简化,将RTSP服务与WebRTC服务融合到一起,这样当两个不同的终端连接到同一个服务器节点时,就可以在其内部直接转流,效率更高。
关于WebRTC协议、应用、源码等相关的内容我之前已经说了很多,有兴趣的同学可以翻看一下我以前的文章,今天我重点讲讲RTSP。
RTSP(Real Time Streaming Protocol)是一个非常古老的协议,它与SIP协议类似,分为信令和传输两部分。其中信令用于建立数据通道,而传输用于发送音视频数据。
首先咱们来看一下RTSP信令相关的内容,RTSP协议支持以下几种信令:
RTSP协议支持的信令还是蛮简单的,那么如何使用这些信令进行音视频的推拉流呢?
下面我就详细描述一下如何使用RTSP协议推/拉音视频流。RTSP客户端向RTSP服务端推流的过程如下图所示:
在推流之前,RTSP客户端向服务端发送OPTIONS信令,查询服务端支持哪些RTSP信令。服务端收到OPTIONS信令后,将其所支持的所信令发送给客户端,如OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE, ANNOUNCE, RECORD, SET_PARAMETER, GET_PARAMETER。
之后客户端向服务端发送ANNOUNCE信令,并带上推送流的描述信息,也就是SDP文件(关于SDP相当的内容我将在后面向你做详细介绍)。服务端收到ANNOUNCE信令并成功解析SDP文件后,向客户端返回200,表示成功。
再下来,客户端向服务端发送SETUP信令。需要注意的是,推送的每一路媒体流都要发送一个SETUP信令,如推送了一路音频和一路视频,那么就要发送两个SETUP,一个是音频的SETUP,另一个是视频的SETUP。
当分别收到音频和视频SETUP的成功响应消息后,客户端需要发送RECORD信令,告之服务端马上我就开始向你推流了。
同样的,当客户收到服务端的RECORD成功响应消息后,RTSP客户端就开始向服务器发送音视频流了。至此,我们就将RTSP推流的过程介绍清楚了,下面咱们再来看一下RTSP客户端拉流的过程。
RTSP客户端从服务器上拉流的过程与推流过程是类似的,如下图所老示:
它还是先发送OPTIONS信令向服务器查询其所支持的信令。之后发送DESCRIBE信令获取可以拉取的音视频流,服务端收到DESCRIBE信令后,返回成功的Reply消息,同时将可以获取的媒体信息以SDP的格式返回。
RTSP客户端收到SDP数据后,对其进行解析,取出可以拉取的音视频流信息,之后为每一路媒体发送一个SETUP信令到服务器。紧接着,发送PLAY信令到服务端拉取音视频流,当收到服务端返回的成功消息后,就可以接收数据了。
以上就是RTSP客户端拉流的基本流程。再接下来咱们了解一下RTSP协议中使用的SDP数据格式。
SDP(Session Description Protocol),也是一种比较老的协议,它是一种描述会话的协议。
它由两部分组成,会话层和媒体层。会话层包括版本、名子、会话时间等内容,而且在SDP中只有一个会话层。媒体层用来表示此次会话包括哪些媒体,如音频、视频。在SDP中可以包括多个媒体层,而且SDP主要描述的就是媒体。
1 | v=0 |
如上这段代码就是一段标准的SDP内容。其中第一个m=行之前的内容属于会话层,之后的属于媒体层。例子中包括两个媒体描述,一个是对视频的描述,另一个是对音频的描述。
m=video 0 RTP/AVP 96
表示是对视频的描述,它指明使用的传输协议是 RTP,AVP(Audioa Video Profile)表示RTP包中的数据格式是由后面定义的。另外,数据负载类型,也就是PayloadType为96。
再下来,b=AS:3298
表示带宽的最大值是3298,AS是Application Specific的意思。
a=rtpmap:96 H264/90000
是媒体属性的描述,它指明视频(PT = 96)使用的是H264编码,时钟频率是 1/90000。
a=fmtp:96 packetization-mode=1; ...
的作用是设置H264编码参数。
a=control:streamid=0
指定控制媒体流播放、暂停等操作的streamid。
后面m=audio 0 RTP/AVP 97
是对音频的描述,各参数的含义与视频类似,这里我就不做赘述了。
现在有不少开源的RTSP服务器可以搭建RTSP环境,比较有名的如 EasyDarwin、ZLMediaKit等。
这里我以ZLMediaKit为例简单介绍一下如何使用它来搭建RTSP环境,具体步骤如下:
我们可以通过下面的命令完成上面三步:
1 | 国内用户推荐从同步镜像网站gitee下载 |
当ZLMediaKit运行起来后,我们就可以使用ffmpeg命令进行推流了,推流命令如下:
1 | ffmpeg -re -i xxx.mp4 -c:v copy -c:a copy -f rtsp -rtsp_transport udp rtsp://127.0.0.1/live/test |
这条命令中各参数的含义如下:
RTSP流推送成功后,我们可以使用ffplay来拉流观看,拉流命令如下:
1 | ffplay rtsp://127.0.0.1/live/test |
拉取RTSP流还是比较简单的,直接告诉ffplay拉流的地址即可。
这里需要说明一下,当我们使用ffmpeg推流时,在其内部就会发送前面我们所讲的RTSP信令,开始与RTSP服务器进行协商要传送哪些流。协商时,就会将要推送的音视频流信息整合到SDP中,并将合成好的SDP发送给服务端。服务端成功解析SDP文件后,会根据里边的媒体信息创建通道,并在ffplay拉流时,将接收到的数据中转给ffplay进行播放。
刚才我介绍的ZLMediaKit也是支持WebRTC协议的,但对WebRTC协议支持到哪种程度我并没有进行测试。不过大体原理还是很简单的,当一个WebRTC客户端连到ZLMediaKit上来之后,它会通过URL告之ZLMediaKit要观看(拉取)哪路流。
之后,ZLMediaKit会将通过RTSP协议收到的音视频包按WebRTC的要求重新打包,之后转发给WebRTC客户端,这样WebRTC客户端就可以看到通过RTSP协议上传的音视频流了。
这里有的细节暂时我就不深入剖析了,等后面有时间的时间再向大家做详细的介绍。
本文重点讲解了RTSP协议的工作流程,让你知道向RTSP服务器推流和拉流的具体步骤是怎样的。特别是有些企业希望通过浏览器监控RTSP摄像头的功能,目前已经有了非常好的解决方案。
当然本文对RTSP的分析主要是从宏观的角度出发的,很多细节都没有触及,比如说如何解析SDP,发送信令和应答信令时要主意CSeq的对应关系等。如果你对这些细节比较感兴趣的话,可以翻看一下EasyDarwin或ZLMediaKit代码,它们的实现都不复杂,可以让你更快速的了解具体细节。
]]>1 | ffmpeg -i input.mp4 -c:v libx264 -crf 23 out.mp4 |
如上面的命令,以前使用上面命令进行转码时,由于它是单线程工作模式,因此只能利用一个CPU内核。而现在改成多线程工作模式后,它可以充分利用你机子上的多个CPU内核,这可以大大加快转码的速度。
消息刚出来时,不少人以为FFmpeg将所有的编解码器都改成了多线程处理,这种显然是一种误读。
接下来,我们就来剖析一下此次FFmpeg对其转码逻辑做了哪些修改。
首先我们来看一下FFmpeg转码工具在单线程模式下是如何进行工作的,其架构如下图所示:
从图中可以看出,在转码时Demuxer首先读取输入文件,之后对其进行解复用,并取出一个个AVPacket发给解码器进行解码。解码成功后,Demuxer再将解码后的AVFrame送编码器,解码器按照用户设置的编码参数对视频帧重新进行编码,最后交Muxer写到输出文件。
上面的整个操作过程都是单线程处理的,这种工作模式虽然对开发者来说降低了其开发难度,但却无法充分利用计算机的CPU资源。比如我们在一个32核的机子上进行转码的话,FFmpeg转码命令只能利用其中一个核,其它CPU资源都浪费了。
为了解决这个问题,各大厂通常的做法是自己开发一个调度程序,同时启动多个FFmpeg转码命令,这样才能将机子上多个CPU资源利用起来。
但这仍解无法解决一个重要的问题,即如果我们要转码的视频特别复杂,包括多个输入、输出文件时,它的转码时间会特别长。如何解决这个问题呢?这次FFmpeg给出了解决方案,就是将转码命令由单线程改为多线程。
接下来,咱们再来看看最新版的多线程工作模式是如何工作的,其架构如下所示:
从图中我们可以看到,将FFmpeg转码命令改成多线程后,Demuxer、Decoder、Encoder、Muxer等每个模块都变成了一个独立的线程,线程与线程之间通过队列进行通信,这样就可以充分利用计算机中的CPU资源,缩短转码的时间了。
我们来举个具体的例子,假设我们使用下面的FFmpeg命令进行转码:
1 | ffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a copy out.mp4 |
对于这个命令来说,它会同时启动一个Demuxer线程,一个视频Decoder线程、一个视频Encoder线程,一个Muxer线程。
首先Demuxer线程从input.mp4中读取音视频包,之后将视频包插入到视频Decoder线程队列中;另一方面,Decoder线程一直在侦听其队列的变化,当发现队列中有数据时,就取出数据偿试解码,并在解码成功后,将其插入到视频编码线程的队列中;同时,视频编码线程也在侦听着其队列的变化,当发现有数据时取出数据,对其进行编码,最后将编码好的数据插入到Muxer的视频处理队列中。
此外,当Demuxer线程读到音频包后,由于不需要重新进行编码,因此其会将音频包直接插入到Muxer线程的音频处理队列中。
最后,Muxer线程会分别从视频处理队列中和音频处理队列中取出音频包和视频包,并比较它们的时间戳大小,再根据其大小将数据包写入到输出文件out.mp4中,直至将所有的音视频数据处理完成为止。
通过上面的描述我们可以发现,由于转码过程中的每个处理步骤都是由单独的线程控制的,或者说都是并行处理的,因此这种工作模式可以充分利用计算机上的CPU资源,大大缩短处理单个多媒体文件的处理时间,大大提高了工作效率。
此外需要特别强调的是,如果转码命令中需要打开多个输入文件,那么对于每个输入文件会创建一个与之对应的Demuxer线程,以及Decoder线程,Encoder线程等,这一点我们一定要清楚。
下面我们就对新版FFmpeg代码做个简单的剖析,首先来看一下main函数:
1 | int main(int argc, char **argv) |
在main()函数中,新增了一个Scheduler类型的变量,该变量用于管理调度线程。它包括了复用调度器、解码调度器、编码调度器、filter调度器、复用调度器等,每个调度器里都包括一个线程任务(Task),用于处理具体的逻辑。
ffmpeg_parse_options(…)函数用于分析命令行中的参数,它将命令行中的参数分成三种类型组,即全局组,输入组及输出组。其中全局组保存全局参数,如 -y
;输入组保存输参数,如-i input.mp4
,-c:v libx264
等;输出组保存输出参数,如out.mp4
。而且每种类型的组都可以有多个,如-i input.mp4
,-c:v libx264
表示有两个输入组。
ffmpeg_parse_options(…)函数除了要分析命令行参数外,它还需要根据分析出的结果打开输入、输出文件。同时根据输入、输出文件创建并初始化各种类型的Task。
其中初始化Task的一项重要工作是为Task绑定对应的处理函数,这里我对每种Task所绑定的处理函数做了整理,如下表所示:
序号 | Task类型 | 处理函数 | 说明 |
---|---|---|---|
1 | DemxerTask | input_thread | 在ffmpeg_demux.c文件中的568行 |
2 | MuxerTask | muxer_thread | 在ffmpeg_mux.c文件中的407行 |
3 | DecoderTask | decoder_thread | ffmpeg_dec.c文件中的611行 |
4 | EecoderTask | encoder_thread | ffmpeg_enc.c文件中的874行 |
5 | FilterTask | filter_thread | ffmpeg_filter.c文件中的2760行 |
transcode(…)函数主要的作用是调用sch_start(sch)将前面创建好的Task启动起来,也就是为每个Task启动一个线程,然后在线程中调用Task所绑写的处理函数。
到此,转码流程就运转起来了!
接下来咱们来看一下Scheduler结构中包括了哪些成员,代码如下:
1 | struct Scheduler { |
上面就是Scheduler结构,它由demux、mux、dec、enc、sq_enc、filters等字段组成,其中每个字段我都做了详细注释,通过注释大家应该基本清楚每个字段的作用是什么了。
需要注意的是,对于demux、mux、dec等字段在结构中虽然看着只是一个指针,但实际上它是一个指向数组首地址的指针,而数组的个数是由其下面的nb_xxx来记录的。所以无论对于一条转码命令来说,其用到的demux、mux等调度器一般都会有多个,而每个都对应着一个线程。
Scheduler结构清楚之后,下面我们来看一下ffmpeg_parse_options( )函数是如何实现的。
ffmpeg_parse_options(…)是一个非常重要的函数,它的主要作用我在前面已经向你做了介绍,下面进入到这个函数中,看一下它具体是怎么做的吧。代码如下:
1 | int ffmpeg_parse_options(int argc, char **argv, Scheduler *sch) |
在该函数中,首先调用split_commandline(...)
函数解析命令行,并将解析的结果保存到octx
结构中。如前面所述,在octx中会将命令行参数分为三种类型组,即全局组、输入组及输出组。
之后调用open_files(...)
函数打开所有输入文件。对于octx.groups[GROUP_INFILE]存放的每个输入文件都调用ifile_open(…)进行处理。在ifile_open中会做以下几件事儿:
再下来调用init_complex_filters( )
创建并初始化filterGraph,对这个函数我们就不展开讲解了,内容比较多,后面再单独介绍。
最后调用open_files(...)
函数打开所有输出文件。对于octx.groups[GROUP_OUTFILE]存放的每个输出文件都调用of_open(…)进行处理。在of_open中会做以下几件事儿:
通过上面的分析我们可以总结出,ffmpeg_parse_options函数主要完成三件事儿,一是分析命令行参数,二是根据分析出的结果打开所有的输入与输出文件,三是为每个输入、输出文件创建SchDemux和SchMux对象,并初始化这两个对象中的Task,为之绑定线程处理函数。
最后我们来看一下transcode函数的实现,其代码如下:
1 | static int transcode(Scheduler *sch) |
在transcode中主要做了三件事儿,其一调用sch_start( )启动Scheduler中的所有任务;其二调用sch_wait( )等待转码结束;其三调用sch_stop( )停止所有开启的任务。
在这三件事儿中,最为核心的是sch_start函数,它会对Scheduler中demux、dec、enc、filtert等字段做遍历,启动每一个任务。也就是为每个任务启动一个线程,并在线程中调用在ffmpeg_parse_options中绑定好的处理函数。
根据测试发现,如果我们只做一些简单的转码任务,如仅将一个输入文件转码成另一种输出文件,那么将FFmpeg命令由单线程改为多线程并不会带来什么好处。其原因是,转码时占用资源最多的是音视频编解码,而默认情况下FFmpeg编解码已经采用了多线程来提高效率,因此无论FFmpeg命令改不改成多线程都不会有什么提升了。
但如果是一些复杂的转码任务,尤其是有多个输入、输出文件时,FFmpeg命令改成多线程后确实会对性能有一定的提升,必竟并行处理要比串行处理快,而且转码时的输入、输出文件越多,这种性能的提升就越明显。
测试代码如下:
1 | !/bin/bash |
本文对新版FFmpeg命令行程序做了一个简单的分析,重点讲解了最新的多线程版与之前单线程版之间的区别,同时对其源码做了简单的剖析。
通过本文你应该知道老的FFmpeg存在那些问题,新版FFmpeg是如何解决该问题的。另外我们要清楚新版FFmpeg是如何使用多线程的,线程之间是如何交互的。
当然由于篇幅的原因,本文并没有将新版FFmpeg分析透,只是打开了一扇门。后面我们陆续推出一些文章对其做更深入的剖析。
FBO(Framebuffer Object),它是一个对象而不是一个缓冲区,不能直接用来存放视频帧。其示意图如下所示:
从图中我们可以看到,FBO包含了三种附着:第一种颜色附着,用来存储渲染结果的颜色信息,可以有多个,每个对应一个颜色缓冲区。颜色附着可以是纹理或渲染缓冲对象,可以用来做一些后处理效果,如光照、阴影、模糊等。第二种深度附着,用来存储渲染结果的深度信息,只能有一个,对应一个深度缓冲区。深度附着可以是纹理或渲染缓冲对象,可以用来实现一些特殊的渲染效果,如遮罩、轮廓、镜面反射等。第三种模板附着,用来存储渲染结果的模板信息,只能有一个,对应一个模板缓冲区。模板附着可以是纹理或渲染缓冲对象,可以用来实现一些特殊的渲染效果,如遮罩、轮廓、镜面反射等。
一般情况下,我们只使用颜色附着和深度附着,颜色附着使用纹理作为缓冲区,而深度附着使用Renderbuffer作为缓冲区。
此外我们还要知道,FBO是用来代替EGL中的Pbuffer的,因为FBO要比Pbuffer高效得多。
接下来我们来看一下如何使用FBO。
使用FBO的第一步是创建FBO对象,而创建FBO对象的时机非常关键,在什么地方创建FBO比较合适呢?
一个比较好的时机是在收到SurfaceView的surfaceChanged事件时创建FBO,这样就可以根据Surface的变化来随时变更FBO了。其代码如下:
1 | private void surfaceChanged(...){ |
在上述代码中,首先创建了一个纹理ID,之后将该纹理ID与GPU进行了绑定。紧接着为纹理分配了空间,并设置了相关的参数,这些内容我们在前面的课程中都做过详细讲解,这里就不赘述了。
之后代码中调用**glGenFramebuffers( )**方法生成了FBO ID,并将FBO ID与GPU进行了绑定。再下来开始创建Renderbuffer。
调用**glGenRenderbuffers( )方法生成Renderbuffer ID,然后让它与GPU进行绑定,同时使用glRenderbufferStorage( )**为Renderbuffer分配空间。
最后调用**glFramebufferRenderbuffer( )和glFramebufferTexture2D( )**方法分别为FBO设置深度附着和颜色附着。
创建好FBO后,接下来咱们看看如何使用FBO实现离屏渲染。
当FBO创建好后,我们如何通过FBO实现离屏渲染呢?其代码如下:
1 | private void doFrame(...){ |
在上述代码中,首先重新将FBO ID绑定到GPU上,之后调用draw( )方法通过OpenGL渲染模型,而渲染后的结果就会默认输出到FBO上。
之后,将FBO与GPU解绑,那么此后输出的内容又会回到之前的逻辑,输出给窗口Surface。因此,接下来调用**mFullScreen.drawFrame(…)方法时,它会从FBO的颜色附着纹理中读取数据,再通过OpenGL将其渲染到窗口Surface中,最后通过mWindowSurface.swapBuffers( )**方法将其展示到屏幕上。
再下来,与二次渲染一样,切换到编码器Surface,并再次调用drawFrame( )方法,让OpenGL将OffscreenTexture纹理中的内容渲染到编码器Surface中,然后通过swapBuffers( )方法将渲染内容送编码器编码。
最后,再将当前Surface恢复为窗口Surface。至此就完成了通过FBO实现离屏渲染的工作。
本文我向你介绍了什么是FBO,如何创建FBO以及如何使用FBO。这里有两个知识点我们需要弄清楚,第一个是当我们绑定FBO后,OpenGL渲染的结果就会被自动写入到FBO中。第二个,当需要将FBO中的内容渲染到屏幕时,首先要解绑FBO,之后把FBO的颜色纹理当作输入,再利用OpenGL就可以将FBO中的内容输出到窗口Surface中了。
首先我们来看一下如何通过二次渲染法实现离屏渲染。
每当我们收到新的视频帧时,都会调用doFrame()方法进行渲染,其代码如下:
1 | private void doFrame() { |
上述代码的逻辑非常清晰,首先调用draw()方法通过OpenGL绘制模型,然后调用swapBuffers()方法将draw()绘制好的结果输出到屏幕上(注意,默认情况下使用的是SurfaceView的Surface)。
接下来调用makeCurrent()方法将当前Surface切到MediaCodec的Surface上,再次调用draw()方法绘制模型。当模型绘制好后,调用mInputWindowSurface.swapBuffers()方法将结果输出绘MediaCodec进行编码。
最后,调用mWindowSurface.makeCurrent()方法将Surface恢复为SurfaceView的Surface为下一帧的绘制作好准备。
以上就是通过二次渲染法进行离屏渲染的具体做法。下面我们再来看看如何通过BlitFramebuffer实现离屏渲染。
通过BlitFramebuffer实现离屏渲染的代码如下:
1 | private void doFrame() { |
从代码中我们可以看到,一开始它也是调用draw()方法渲染模型,但接下来并没有直接将渲染后的结果输出到屏幕上,而是调用了mInputWindowSurface.makeCurrentReadFrom(mWindowSurface)方法来切换Surface。需要注意的是,makeCurrentReadFrom()方法与makeCurrent()方法不同,makeCurrent()方法会将读取的Surface和写入的Surface同时切换成同一个Surface,而makeCurrentReadFrom()方法可以让读取与写入的Surface不同。在这个例子中,它将写入的Surface换成了MediaCodec的Surface,而将读取的Surface的是成了SurfaceView的Surface,其含义就是从SurfaceView的Surface中读数据写到MediaCodec的Surface中。
当设置好Surface后,就调用glBlitFramebuffer方法完成具体的拷贝工作。之后调用mWindowSurface.makeCurrent()方法先将Surface中的内容送编码器进行编码,然后调用mWindowSurface.makeCurrent()方法将当前Surface切回到SurfaceView的Surface上,最后调用mWindowSurface.swapBuffers()方法将渲染结果显示到屏幕上。
本文我向你分别讲述了二次渲染和BlitFramebuffer两种不同的离屏渲染技术对渲染结果进行屏幕展示和编码的过程。
这两种方法实现起来都比较简单,只要我们熟悉EGL环境,知道如何切换Surface,那么对上面的内容都可以轻松理解。
在Android应用程序中创建线程是非常简单的事儿,但在什么时刻创建是比较讲究的。最佳的创建时机我们在OpenGL离屏渲染中也介绍过,即在surfaceCreated()函数被调用的时刻最为合适,因为surfaceCreated()函数被调用时说明SurfaceView中的Surface已经被成功创建,此刻开启线程就可以直接操作该Surface了。
为了surfaceCreated()函数能够被成功调用,首先需要让App的Activty类实现SurfaceHolder.Callback类中的接口,然后在Activity中的onCreate(…)方法中将Activity设置为SurfaceView的回调对象,这样当SurfaceView中的Surface创建好后,就会回调Activity的surfaceCreated()方法,代码如下:
1 | ... |
在上面代码的surfaceCreated()方法中,首先创建了一个RenderThread对象,紧接着调用了该对象的start()方法启了一个新线程。
下面我们来看一下渲染线程是如何实现的,代码如下:
1 | private static class RenderThread extends Thread { |
通过上面的代码我们可以知道,RenderThread继承自Thread,并且重载了run()方法。在当我们调用RenderThread的start()方法时,它就是开启一个新的线程,并在新的线程中运行RenderThread的run()方法。
在run()方法中,首先创建了RenderHandler对象,该对象用于处理主线程发来的消息;之后创建EglCore对象,在其构造函数中会创建EGL环境;最后执行Looper.loop()等待消息。
下面我们就来分析一下,在EglCore的构造函数中是如何建立EGL环境的,代码如下:
1 | ... |
在EglCore的构造函数中,首先调用**EGL14.eglGetDisplay(…)方法加载OpenGLES库,之后调用EGL14.eglInitialize(…)**方法初始化OpenGLES库,紧接着根据用户的要求创建OpenGLES上下文。如果用户要求创建OpenGLES3.0上下文且OpenGLES库支持到3.0那么就建立OpenGLES3.0上下文,否则建立OpenGLES2.0上下文。
至此,我们就将渲染线程的EGL环境构建好了。
本篇文章我向你详细介绍了在Android系统下如何自己启动一个渲染线程,如何在渲染线程中构建EGL环境。实际上,其实现与GLSurfaceView的实现是很类似的,我们完全可以参考GLSurfaceView的源码来实现自己的渲染线程。
另外一点,渲染线程的创建时机是比较有讲究的,一般是在SurfaceView中的Surface创建好后再开启渲染线程,这样可以让我们的代码更简洁、丝滑!
要想将OpenGL渲染出的结果保存成mp4文件,我们需要使用一种称为离屏渲染的技术。
什么是离屏渲染呢?顾名思意,就是让OpenGL不将渲染的结果直接输出到屏幕上,而是输出到一个中间缓冲区(一块GPU空间),然后再将中间缓冲区的内容输出到屏幕或编码器等目标上,这就称为离屏渲染。
使用离屏渲染有什么好处呢?比如我们可以让OpenGL将渲染的结果先输出到一个帧缓冲区中,然后再把帧缓冲区的内容送给编码器进行编码,最后写到mp4文件中,这样就达到我们的目标了。当然在将结果保存成mp4文件的同时,还能让它在屏幕上显示出来就更好了,那么如何做到这一点呢?
在Android系统下,有三种方法可以实现,下面我们就来一一介绍一下。
在Android系统下可以使用三种方法实现同时将OpenGL的内容输出给多个目标(屏幕和编码器)。第一种方法是二次渲染法;第二种方法是使用FBO;第三种是使用BlitFramebuffer。
首先我们来看看如何通过二次渲染法实现将OpenGL渲染结果送给屏幕和编码器。
想通过二次渲染法实现OpenGL渲染结果送屏幕和编码器,我们必须使用SurfaceView,而不能使用GLSurfaceView。
之所以如此,是因为GLSurfaceView有自己的专有渲染线程,这虽然减少了开发者使用OpenGL的复杂度,但也同时降低了开发者对EGL的控制力,显得不够灵活,从而无法将OpenGL的渲染结果输出给多个目标。
为了解决这个问题,我们需要使用 SurfaceView+自己创建渲染线程 这个组合,在自己创建的渲染线程中使用EGL API,通过多次渲染并将结果输送给多个目标Surface来实现二次渲染,其架构如下图所示:
在上图中,我们可以看到有 SurfaceView, MediaCodec, Camera, OpenGL/EGL等组件。
其中SurfaceView用于展示OpenGL的渲染结果,而且它还实现了两个重要的事件处理方法,surfaceCreated和surfaceChanged。surfaceCreated方法是在SurfaceView中的Surface创建好后被调用。在该方法中,首先创建一个渲染线程,并在渲染线程中创建EGL环境。而surfaceChanged方法是在Surface发生变化时被调用,在该方法中我们会创建FBO环境,关于这方面的内容我在讲解FBO时再向你做详细介绍。
MediaCodec用于编码,它也有一个Surface用于接收需要编码的数据。
Camera用于采集视频数据,当采集到视频数据后,它会通知渲染线程,渲染线程通过SurfaceTexture从BufferQueue中取走数据,并交由OpenGL处理。
OpenGL/EGL用于渲染,它收到视频帧后调用Shader程序进行渲染,之后将渲染后的结果输出给SurfaceView的Surface,让其在屏幕上显示;接下来,调用EGL的eglMakeCurrent方法,将默认Surface从SurfaceView的Surface改为MediaCodec的Surface,然后再次调用Shader程序进行渲染,并将渲染后的结果输出给MediaCodec的Surface进行编码。也就是说,二次渲染就是调用两次Shader程序进行渲染,每次渲染后的结果输送给不同的目标Surface,因此称为二次渲染法。
当然,如果你需要将渲染结果输出给多个目标也是可以的,那就需要调用多次Shader程序,每次将渲染结果输送给不同的目标Surface就好了。
上面的方法虽然能够将同一个源输送给不同的目标,但每次都要调用OpenGL进行重绘,效率上确实不高。尤其是当我们要渲染的模型特别复杂的时候,会严重影响效率,有没有更好的解决办法呢?
OpenGL为我们提供了一种高效的办法,即**FBO(FrameBufferObject)**。下面我们来看一下如何通过FBO高效的向多个目标输送渲染结果,其架构图如下所示:
其架构图与我们上面展示的二次渲染架构图很类似,只不过通过OpenGL渲染的结果不再直接送给不同的Surface,而是将结果输出到FBO中,这里你可以先将FBO想像为一块特殊的空间(关于FBO的细节,我会单独写一篇文章进行讲解)。
然后通过另外一种Shader程序(这种Shader程序是专门用于处理FBO纹理的)再次进行渲染。当然,这种Shader程序由于处理的是纹理,所以会比第一次使用的Shader程序效率高很多。
渲染成功后将结果输出给屏幕,然后切换Surface再次调用Shader(第二个Shader),这次渲染的结果将会输送给MediaCodec的Surface进行编码。
因此,通过FBO方法我们只需要对原模型渲染一次,将结果保存到FBO。之后再对FBO中的内容进行多次渲染,通过这种方式来提高效率。
注意,之所以通过这种方式可以提高效率,原因就在于OpenGL对FBO纹理的渲染效率远远高于对原模型的渲染效率。
通过上面的描述,我们仍然觉得不够高效。有没有更好的方法,只渲染一次就可以将结果输送给多个目标呢?到了OpenGL3.0出现了一种更高效的方法,即BlitFramebuffer。
它是如何工作的呢?其工作架构图所下所示:
该方法不再使用FBO做缓存,而是像二次渲染法一样,先将渲染的内容输出到当前Surface中,但并不展示到屏幕上。
相当于把当前的Surface当作一个缓冲区,然后切换Surface,此时MediaCodec的Surface变成了当前Surface,接下来利用OpenGL3.0提供的API BlitFramebuffer从原来的Surface拷贝数据到当前Surface中,再调用EGL的eglSwapBuffers将Surface中的内容送编码器编码。之后再将当前Surface切回原来的Surface,也就是SurfaceView的Surface,同样调用EGL的eglSwapBuffers方法,将其内容显示到屏幕上。
至此,实现了OpenGL仅渲染一次,却可以输出给多个目标,这种方法是最高效的。
本文向你介绍了三种离屏渲染的方法,其中最高效的是BlitFramebuffer方法,但它是在OpenGL3.0才有的API,因此只有OpenGL3.0才可以使用该方法;其次是FBO方法,但其复杂度要比二次渲染法高很多,并且效率是有提高与原模型的复杂高有很大关系,如果原模型复杂度并不高,那么它与二次渲染法的效率并不多。
此外,本篇文章中还有很多细节并没有介绍到,比如如何使用FBO,如何在自己创建的渲染线程中构建EGL环境,如何使用BlitFramebuffer等,这些内容我将在后面的文章中再向你做详细讲解。
如上图所示,图中左侧是图片的原始尺寸,其高度和宽度都没有将屏幕覆盖全,而右侧是图片被拉伸的情况。
如何才能防止图片被拉伸呢?这里我介绍两种方法,首先我们来看第一种方法。
第一种防图片被拉伸的方法是改变视口大小,即根据图片和窗口的宽高比计算视口的大小,从而让图片宽度占满屏幕上下留白或高度占满屏幕左右留白。
具体算法如下:
当确定好是图片的宽度占满窗口或图片的高度占满窗口后,接下来我们再计算图片的高度或宽度,具体计法方法如下:
如果图片的宽度占满窗口宽度,则图片的高度为:窗口的宽度 / 图片的宽高比,即 $W_W / (W_I/H_I) = (W_W/W_I)*H_I$
如果图片的高度占满窗口高度,则图片的宽度为:窗口的高度 * 图片的宽高比,即 $H_W * (W_I/H_I) = (H_W/
H_I) * W_I$
此外,我们还要计算图片的起始点位置,如下图所示:
图片最初的起始点为窗口左上角,但当我们修改视口后,其起始点也发生了变化,那么这个启始点该如何计算呢?计算公式如下:
$$
X = |W_W - W_I| / 2 \
Y = |H_W - H_I| / 2
$$
有了起始点和图片的宽高,我们就可以使用OpenGL为我们提供的函数glViewport来设置视口的位置了,即
1 | glViewport(X, Y, vWidth, vHeight) |
由于该视频的宽高比与图片的宽度比一致,因此我们渲染图片时,让图片将视口填满即可达到最佳的展示效果。
当然,除了上面介绍了修改视口来防止图片被拉伸的方法外,还有一种方法可以实现图片被拉伸,这种方法被称为投影变换。
如我们在第9章中介绍的,投影变换分为正交投影和透视投影,对于图片的渲染来说,使用的是正交投影。
接下来我们就来看一下如何使用正交投影实现图片的渲染。
咱们先来回顾一下正交投影的过程,如下图所示:
对于正交投影来说,首先要进行平移,将模型的中心点移到坐标的中心点处,之后对模型进行缩放,使之缩小到一个单位空间内,最后将3D模型投影到2D屏幕上。
对于图片渲染来说,当我们按照上述方法操作时就会得到一个宽度与窗口一致上下留白或高度与窗口一致左右留白的图像。
那么具体该如何做呢?其实非常简单,我们只需要让图片乘以一个正交变换矩阵就可以了。而正交变换矩阵在前面的课程中我们也推导过,如下所示:
$$
M_{ortho} =
\begin{bmatrix}
\frac{2}{r-l} & 0 & 0 & 0\
0 & \frac{2}{t-b} & 0 & 0\
0 & 0 & \frac{2}{n-f} & 0\
0 & 0 & 0 & 1
\end{bmatrix}
\cdot
\begin{bmatrix}
1 & 0 & 0 & -\frac{r + l}{2} \
0 & 1 & 0 & -\frac{t + b}{2} \
0 & 0 & 1 & -\frac{n + f}{2} \
0 & 0 & 0 & 1
\end{bmatrix}
$$
这个矩阵还是蛮复杂的,想记住它可不容易,不过好在Android已经为我们提供了现成的方法,只要我们调用下面这个方法就可以得到这个矩阵:
1 | Matrix.othroM(mvpM, offset, l, r, b, t, n, f) |
其中,mvpM是输出矩阵;offset是偏移量,一般设置为0;l代表模型在单位立方体中的左边界;r代表模型在单位立方体中右边界;b代表模型在单位立方体中底部边界;t代表模型在单位立方体中顶部边界;n代表模型在单位立方体中的近边界;f代表模型在单位立方体中的远边界。
由于图片是二维的,所以n和f不会改变,我们将它们设置为-1和1即可。对于宽高来说,则需要根据实际情况来设定。如果上下留白,则左右设置为-1, 1;如果左右留白,则上下设置为-1,1。
那么接下来,我们就来看看如何计算矩阵的宽高。具体算法如下:
当图片的宽高比 大于 窗口的宽高比时,模型的高度为:图片的宽高比除以窗口的宽高比,即$tb = (W_I/H_I)/(W_W/H_W)$。此时我们需要将Matrix.othroM方法中的b和t分别设置为 -tb 和 tb。
否则,当图片的宽高比小于等于 窗口的宽高比时,模型的宽度为:窗口的宽高比除以图片的宽高比,即$lr = (W_W/H_W)/(W_I/H_I)$。此时我们需要将Matrix.othroM方法中的l和r分别设置为 -lr 和 lr。
这样我们就得到了正确的正交变换矩阵,之后用这个正交变换矩阵就可以将图片正确的渲染到屏幕上。
上面我向你介绍了两种在渲染图片时防止被拉伸的方法,其中第一种是通过修改视口的方法实现的,而第二种是通过得到正交变换矩阵的方法实现的。
通过修改视口的方法相对来说更容易理解一些,但更常见的方法是通过求正交变换矩阵来得到正确的渲染结果,这种方法适用性更强,也更灵活。
我们使用GPU时,一个非常重要的优化点是CPU与GPU之间的数据传输。一个重要原则是:使用Shader时尽量不要在CPU与GPU之间频繁的传输数据。在CPU与GPU之间频繁的传输数据会大大降低程序的执行效率,严重时甚至不如直接使用CPU进行渲染。
为了解决这个问题,OpenGL为我们提供了几种优化方法,如VBO、EBO与VAO。每种方法都有其特定的应用场景,下面我们就分别讲解一下这几种方法该如何使用。
首先我们来看一下VBO(Vertex Buffer Object),其作用是在GPU中存放顶点数据。利用VBO技术,我们可以将主内存中的顶点数据一次性拷贝到GPU中,来提高处理效率。
具体操作步骤如下:
具体代码如下:
1 | ... |
上面的代码很简单,就是按照创建VBO对象、与GPU绑定、拷贝数据到GPU……这样一个步骤来实现的。代码中每一行的作用我都做了注释,通过注释相信你很容易理解每一行的作用了。
了解了VBO之后,接下来咱们再来看看EBO的作用。
EBO(Element Buffer Object)也称为IBO(Index Buffer Object),其作用是缓存顶点索引值。在OpenGL中,每个顶点对应一个索引值,而通过索引仠我们就可以找到某个顶点。
需要注意的是,使用EBO时,我们必须先使用VBO将顶点坐标保存到GPU中,这样索引值才能与顶点坐标一一对应。
EBO的使用与VBO是类似的,其使用步骤如下:
具体代码如下:
1 | ... |
使用EBO与使用VBO非常类似,都是先创建EBO ID,之后绑定到GPU,再将顶点Index拷贝到GPU。最大的区别是绘制模型的触发函数,VBO使用的是glDrawArrays方法,而EBO使用的是glDrawElements方法。
最后我们来看一下VAO。VAO(Vertex Array Object)的作用是存放VBO ID,我们将多个VBO ID存入在VAO中。换句话说,你可以认为VAO是VBO的管理者。如果每个VBO是一个模型的话,那么VAO可以同时管理多个模型。
通过上面的描述我们可以知道,使用VAO的前提是先构建VBO,尤其是有多个VBO时使用VAO才有意义,否则没有必要使用VAO。
如何使用VAO呢?使用步骤如下:
来看一下具体的例子:
1 | ... |
通过上面的代码我们可以知道,使用VAO与直接使用VBO差别不大,只是在一开始创建了VAO ID,并将它绑定到了GPU上,之后触发模型绘制之后解绑VAO就完事儿了。正如我们前面所介绍的,VAO就是用于存放VBO的,有了该对象可以让我们同时处理多个VBO,否则的话每次只能处理一个,就会很麻烦。
上面我就将使用VBO、EBO和VAO提高Shader工作效率的方法向你介绍清楚了。通过这篇文章我们可以知道,EBO和VAO都是以VBO为基础的,如果不有VBO我们就使用不了EBO和VAO。
另外我们还要清楚,之所以使用VBO、EBO、VAO可以提高Shader的工作效率是因为它们会先将顶点数据拷贝到GPU中,这样以后每次用到顶点数据时可以直接从GPU获取,而不必频繁的在CPU与GPU之间传输数据,这才是VBO、EBO、VAO可以提升效率的原因。
我们之所以能够看到这缤纷的世界,能够看到各式各样的物体,能够看到美丽的图画都要归功于光。
当光照射到物体后,物体会将一部分光反射到我们的眼睛里,而我们的眼睛会从中提取必要的信息传给大脑,这样在我们的大脑中就会“构造”出一幅图像,而这幅图像就是我们看到的景像了。这就是我们通过人眼能看到物体的真正原因。
实际上,无论是声音、紫外线还是光,它们都是一种电磁波。对于光而言,光传播的频率决定了光的颜色,频率越高颜色越靠近紫色,而光传播时的振幅决定了光的亮度,振幅越大亮度越高。
其实,我们人眼可见光的频谱是非常窄的,如下图所示。
在$10^6$到$10^{19}$这么大范围内的电磁波中,只有$10^{14}$到$10^{15}$范围内的一小部分才是人眼可见的。因此,俗话中所说的“眼见为实”并不靠谱!
1966年,牛顿通过三棱镜发现白光是由多种颜色光混合而成的。根据这个原理,人们进一步发现只需要RGB(红色、绿色、蓝色)三种颜色就可以组合出人眼可以看到的各种颜色的光。并且在RGB三色光中,人眼对绿色最为敏感,其次是红色,最后是蓝色。
后来人们根据用途不同构建了好几种色彩空间,如用于显示设备的RGB色彩空间;用于打印设备的CMYK色彩空间;用于电视和视频压缩的YUV色彩空间;绘画使用的HSV色彩空间以及描述人眼的HSL色彩空间。这些色彩空间有大有小,而且由于用途不同,还会有一些色差。
对于视频渲染来说,用的最多的是RGB和YUV。由于经常需要进行两种色彩空间的转换,所以我们必须熟悉这两格色彩空间的区别和数据格式。
如我们上面所述,屏幕使用的是RGB方式显示图像,即通过三元色(红、绿、蓝)组合成各种颜色。屏幕上的每个像素点都是由R、G、B三个发光二极管构成的。当R、G、B全部点亮时为白色,全灭时为黑色。这样当我们在屏幕上显示信息时,只要控制每个像素点中R、G、B发光二极管的明暗程度就可以显示出我们想要的效果了。
而YUV是彩色电视使用的。其中Y分量用于控制明暗程度,UV分量用于控制颜色。之所以电视使用YUV方式显示图像,是因为在彩色电视之前大家使用的是黑白电视。而黑白电视只需要Y分量,当彩色电视出现之后,为了兼容黑白电视,因此才会出现YUV格式,这样无论是彩色电视下是黑白电视我们给它传输YUV数据时,它们都可以正常工作。
对于YUV来说,有三个重要的标准,分别是BT.601、BT.709和BT.2020。这三个标准分别代表三种不同的YUV色彩空间,其中BT.601是标清色彩空间,BT.709是高清色彩空间,而BT.2020是超高清色彩空间。针对于不同的色彩空间,RGB与YUV互转时使用的公式也是不同的。
BT.601与RGB互转公式如下:
$$
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
=\begin{bmatrix}
0.299 & 0.587 & 0.114 \
-0.14713 & -0.28886 & 0.436 \
0.615 & -0.51499 & -0.10001
\end{bmatrix}
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
$$
$$
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
=\begin{bmatrix}
1.0 & 0.0 & 1.13983 \
1.0 & -0.39465 & -0.58060 \
1.0 & 2.03211 & 0.0
\end{bmatrix}
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
$$
BT.709与RGB互转公式如下:
$$
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
=\begin{bmatrix}
0.2126 & 0.7152 & 0.0722 \
-0.09991 & -0.33609 & 0.436 \
0.615 & -0.55861 & -0.05639
\end{bmatrix}
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
$$
$$
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
=\begin{bmatrix}
1.0 & 0.0 & 1.28033 \
1.0 & -0.21482 & -0.38059 \
1.0 & 2.12798 & 0.0
\end{bmatrix}
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
$$
BT.2020与RGB互转公式如下:
$$
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
=\begin{bmatrix}
0.2627 & 0.6780 & 0.0593 \
-0.1396 & -0.3604 & 0.5 \
0.5 & -0.4598 & -0.0402
\end{bmatrix}
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
$$
$$
\begin{bmatrix}
R \
G \
B
\end{bmatrix}
=\begin{bmatrix}
1.0000 & 0.0000 & 1.47460 \
1.0000 & -0.16455 & -0.57135 \
1.0000 & 1.88140 & 0.00000
\end{bmatrix}
\begin{bmatrix}
Y \
U \
V
\end{bmatrix}
$$
通过这篇文章我们应该知道光也是一种电磁波,而且可见光在整个电磁波范围内占比非常小。我们可以使用三元色的不同组合构造出各种颜色,这就是RGB。RGB通常应用于屏幕,因此我们在进行渲染时,就需要先将图像转成RGB,然后才能将其渲染到屏幕上。
另外,视频图像使用的是YUV格式而不是RGB。如果我们想将YUV格式的数据渲染到屏幕上,那么首先需要将其转成RGB,然后才能渲染到屏幕上。当然对于OpenGL来说,其内部已经提供了YUV转RGB的方法,因此在进行YUV到RGB转换时,不需要我们自己实现,调用OpenGL提供的方法即可。
另一方面,有时我们也需要将屏幕上渲染的图像保存成MP4、JPEG等格式,此时需要将RGB转成YUV,才能交给编码器。同样我们也不需要知道YUV与RGB之间具体该如何互换,唯一需要清楚的是,编码器要的是YUV而不是RGB。
首先我们来看一下什么是矩阵,如下图所示:
$$
\begin{bmatrix}
0 && 1 \
2 && 0 \
3 && 4
\end{bmatrix}
$$
图中是一个三行两列的矩阵,通过这张图我们可以给矩阵一个简单的定义:它是一个由数字排列而成的矩形数组。
它的应用非常广泛,最常见的是解多元一次方程,比如下所示:
$$
3x + 4y + 5z = 16 \
2x - 3y - 4z = 8 \
7x + 2y - 3z = 28
$$
我们可以将上面的三元一次方程用矩阵的方式表达:
$$
\begin{bmatrix}
3 && 4 && 5 \
2 && -3 && -4 \
7 && 2 && -3
\end{bmatrix}
\cdot
\begin{bmatrix}
x \
y \
z
\end{bmatrix}
= \begin{bmatrix}
{\textcolor
视频、图像的渲染实际上是属于图形学的范畴,在图形学上为了提高渲染图像的效率和灵活性,提出了渲染管线。
渲染管线分为固定渲染管线和可编程渲染管线。其中固定渲染管线指的是,图像在渲染时必须按照固定的顺序一步步执行,用户在渲染开始前提供需要渲染的数据和参数,然后等待渲染结果;而可编程渲染管线指的是,在图像渲染时仍然按照固定的顺序一步步执行,但在某些阶段,如顶点着色器阶段、片元处理阶段,可以通过编写Shader程序进行控制。在Android系统下,OpenGLES1.0采用的是固定管线,而从OpenGLES2.0之后则变成了可编程管线。
如上图所示,图形渲染管线从大的方面分为:应用阶段、几何阶段和光栅化阶段。其中,应用阶段是在CPU中运行的,而几何阶段和光栅化阶段是在GPU中运行的。
应用阶段主要负责提供各种渲染数据和Shader程序;几何阶段又分为顶点着色器阶段、几何着色器、裁剪和屏幕映射四个小阶段;光栅化阶段分为连接三角形、遍历三角形、片元着色、片元操作几个小阶段。
现在我们以顶点着色器阶段为例,看看使用固定管线与使用可编程管线的区别。依然如上图所示,我们可以看到在顶点着色器阶段需要做三件事儿:模型变换、视图变换、顶点着色。如果我们想绘制一个三角形,在Android系统下采用固定渲染管线时,我们需要将顶点坐标、模型变换矩阵和视图变换矩阵通过OpenGL接口传给GPU,这样在GPU内部它就会按照管线的步骤一步步执行最终将三角形绘制出来。代码如下:
1 | ... |
通过上面的代码我们可以看到,在使用固定管线渲染三角形时,我们只需要调用指定的API将顶点坐标、模型变换矩阵、视图变换矩阵传给GPU,然后再调用glDrawArrays方法启动绘制即可将三角形绘制出来。
而使用可编程渲染管线则与固定渲染管线不同,我们首先要编写Shader程序,然后将数据传给Shader程序,最后由Shader程序控制顶点着色器最终将三角形绘制出来。代码如下:
1 | ... |
上面代码中,我们先实现了Shader程序,然后编译、链接该程序。当链接好程序好后就可以将它传给GPU运行了。在Shader程序中,我们定义了aPos、model、view、projection等变量用来接收用户传入的顶点数据、模型变换矩阵、视图变换矩阵、投影变换矩阵。最后,当用户调用glDrawArrays后,触发可编程管线运行,可编程管线在顶点着色器阶段运行顶点Shader,这样渲染过程就运转起来了。
至此,我们应该清楚图形渲染管线的作用(为了提高效率和灵活性)及固定管线与可编程管线的区别(都是按固定步骤对图像进行渲染,只不过可编程渲染管线中的某些阶段可通过程序进行控制,这样提高了图型渲染的能力,可以渲染出更加复杂的效果)。而渲染管线各阶段的详细作用我在课程中都做了详细介绍这里就不赘述了。
接下来我们来看看如何编写Shader程序。
如上面我们在渲染管线中讲到的,对于可编程渲染管线来说,有好几个阶段是可编程的,其中我接触最多的是顶点Shader和片元Shader。顶点Shader是在顶点着色阶段被调用,而片元Shader是在片元着色阶段被调用。除了这两个阶段外,几何着色阶段也可调用Shader,该阶段被调用的Shader称为几何Shader。不过由于几何Shader是在OpenGL3.2之后才实现的,所以Android下的OpenGLES是不支持的。接下来咱们就来看看如何实现顶点Shader和片元Shader。
顶点Shader顾名思义,就是在顶点着色阶段调用的Shader。它的工作往往非常简单,就是确定绘制图形顶点的位置。比如我们要绘制一个三角形,它有三个顶点,那么这三个顶点在标准设备坐标系下的坐置就是由顶点Shader确定的。
我们先来看一下顶点Shader的代码,如下:
1 | // 定义顶点着色器 |
上述代码中,第1行指定了使用的OpenGL版本,300表示使用的是OpenGL3.0,es表示使用OpenGL的嵌入式版本,即OpenGLES;第2-3行中的in表示这两个变量都是输入变量,而前面的Layout(location=…)指明了变量的位置,通过这种方式,外面向该变量传数据时,就不再需要通过glGetAttribLocation()函数获取其位置了,可以直接使用glVertexAttribPointerb()函数对其赋值。上面的这种写法是OpenGL3.0的新语法;第4-6行没什么特别的,这里不赘述了;第7行中的out指明vColor是一个输出变量,该变量是片元Shader的输入变量,因此在片元Shader中一定有一个与该变量名一样,但前面有in修饰的变量;最后面的main()函数逻辑非常简单,计算每个顶点的位置,并将顶点位置输出给gl_Position,同时将传入的颜色值输出到vColor。
片元Shader的作用是对图形中每个像素的颜色进行填充。我们来看一个具体的例子:
1 | String fragmentShaderSource = "#version 300 es\n" |
上述代码中第1行代码指定了使用的OpenGLES版本为3.0;第2行指明vColor变量的数据是从顶点Shader中获得的;第3行out指定FragColor是一个输出变量;后面的main函数逻辑非常简单,就是将顶点Shader传过来的颜色直接输出给FragColor,也就是输出到屏幕上。
从上面我们可以看到其实OpenGL的Shader程序并不难,它使用的是类C语言的语法,其中顶点Shader负责处理图形的顶点位置,而片元Shader用于为图形中的像素着色。
本篇我们对图形渲染管线和Shader编程进行重新梳理,在我看来,如果想学好OpenGL必须要将图形渲染管线的作用,工作机制搞清楚,只有这样你才能真正的学好、用好OpenGL。
在我们开始之前,咱们先来看一张图,这张图就是Android系统下OpenGL的工作原理图。
咱们首先看一下这张图中都包括也哪些组件,我们按从左到右的顺序一一讲解。
Surfaceview,它是View的子类,它自己会管理一个单独的Surface,因此这种类型的View更具有灵活性,尤其可以实现窗口浮动的功能。
GLSurfaceview,是Surfaceview的子类。我们使用OpenGL对视频、图像渲染时,一般不直接使用Surfaceview。其原因是,Surfaceview虽然灵活但其内部并没有自动构造OpenGL环境,而开发者自己实现这部分代码还是挺麻烦的,尤其是很多同学对Android的显示系统并不清楚,这种情况下很容易掉入各种“坑”里。而GLSurfaceview则为我们解决了这个问题,在其内部,会自己启动一个新的渲染线程,并在这个渲染线程中构造OpenGL环境,这样就大大减轻了开发者的负担。
Surface我们应该都非常清楚了,它是一个视频帧缓况区队列的管理者,用于在不同的进程之间传递视频帧或图像。每当我们创建一个Surfaceview或GLSurfaceview对象时,在其内部就会自动创建一个Surface。
EGL,它是OpenGLES的上下文,在我们使用OpenGL之前首先要先创建这个上下文。在创建EGL时,它会做以下几件事儿:
EGLSurface,它是OpenGL的输出缓冲区,当我们使用OpenGL渲染视频、图像时,会将结果输出到EGLSurface中。其实你也可以认为它是Surfaceview中Surface的外外壳,最终经过OpenGL渲染的数据会交由Surface管理。此时,OpenGL就与Surfaceview或GLSurfaceview建立好了连接。
OpenGLES,指是就是OpenGL库,当EGL创建好后,我们就可以使用OpenGL API调用Shader程序了。
GPU,它是图形处理器,大家对它应该不陌生,主要用于图形处理和并行计算。OpenGL库就是一种可以访问和控制GPU的接口。我们可以通过OpenGL API 向GPU传送数据,发送指令以及从GPU中获取计算的结果。
SurfaceFlinger,它是Android系统的显示服务。当我们通过OpenGL让GPU对图像处理后,会将结果保存到EGLSurface中。而SurfaceFlinger则会为每个View中的Surface创建一个Layer,并让Layer成为Surface的消费者,从Surface中读取数据交由SurfaceFlinger时行合成。
Screen,它表示的是显示器,上面SurfaceFline通过Hardware(GPU)合成图层工作后,就会将最终的结果输出给Screen显示出来。
至此上述图片中的每个组件我都向你介绍清楚了。
那么大家比较关心的一个问题来了,OpenGL是如何高效渲染视频、图像的呢?
我们还是以从Camera采集到一帧数据,然后经过OpenGL渲染显示出来这个过程为例,看看为什么OpenGL可以高效渲染视频、图像的吧。
当Camera采集到视频帧后,它会在Camera硬件的空间中分配一块缓冲区来存放采集到的视频帧(注意这块缓冲区不是在主内存中分配的)。然后Camera会将这块这空间的地址或叫句柄保存到Surface的BufferQueue中。
如我上面所述,在App中我们会创建一个SurfaceTexture作为该Surface中BufferQueue的消费者,当SurfaceTexture发现BufferQueue中有数据时,就会将其取出转成纹理交给GPU。此时,在系统的底层会通过DMA技术将Camera中存放的数据拷贝到GPU中。
当我们通过OpenGL的Shader程序处理好GPU中的视频帧、图像之后,处理后的数据仍然保留在GPU中,而它的句柄则会输出到EGLSurface中,也就是Surface的BufferQueue中。
同样的道理,当SurfaceFlinger的Layer从BufferQueue中取数据时,它取出的仍然是视频帧或图像的句柄,真正要处理的数据此时仍然在GPU中。当SurfaceFlinger对多个Layer合并时,它会把具体的任务交给GPU,这样GPU就可以从它所管理的内存空间中直接取出数据进行合并工作了。
这样是不是大大提高了渲染的效率呢?这就是OpenGL高效渲染的过程。
在这篇文章中,我向你介绍了Android系统下OpenGL的工作原理,并且向你详细阐述了从Camera采集的视频帧传到GPU中进行渲染的,并如何通过Android的显示服务最终展示到屏幕上的。
通过这篇文章,我想你应该十分清楚OpenGL在Android系统下为什么可以高效工作了。尤其是当我们将上一篇文章与本篇文章放在一起阅读时,你的体会会更加深刻。
那么今天,我们就来聊聊Android系统下的显示系统,看看它到底是如何工作的!
在开始之前,我们先通过一张图从整体上一窥Android的显示系统。
首先我们来看看这张图中都包含那些组件,我们按照从左到右的顺序进行介绍:
Camera,它是一个硬件设备,用于采集视频帧。
Surface,它是一个帧缓冲区管理器,它里边管理着一个BufferQueue,而BufferQueue中存放着是一幅幅图像。
BufferQueue,它是一个存放图象的队列,它里边的每个元素都存放着一幅图像。
SurfaceTexture,一方面它可以作为BufferQueue的消费者,当BufferQueue中有数据时,它可以从中取出数据;另一方面它可以将图像转成纹理交由OpenGL处理。
OpenGLES,它利用GPU对图像进行加速渲染或对图像做各种特效处理。
WindowManager,用于管理Window。
Window,它是一个抽象的概念,它表示屏幕上的一个矩形区域,用于显示一个应用程序的界面。
每个Window中管理着1到多个View,每个View就是应用程序中的一个页面。
SurfaceView,它是View的一个子类,每个SurfaceView都有自己的Surface用于存放要显示的数据。由于它并没有与父类共用同一个Surface,所以它的内容是可以浮在父类View之上的。
TextureView,它也是View的一个子类,它没有自己的Surface,而是与父类共用同一个Surface。它的好处是当屏幕发生旋转时,父类View可以正常旋转,而它父类共用Surface的TextureView所绘制的内容也可以正常旋转,不用单独处理了。而SurfaceView则需要开发者自己处理旋转问题。
GLSurfaceView是SurfaceView的子类,它在SurfaceView的基础上开了一个新的线程专门处理OpenGL相关的事儿。比如建立OpenGLES上下文环境,调用OpenGLES API进行渲染等。
GLSurfaceView.Render类,它用于调用OpenGLES API进行渲染,GLSurfaceView的渲染线程周期性的刷新视频帧,每次刷新的时候就会调用GLSurfaceView.Render类的Draw()方法。
SurfaceHolder是GLSurfaceView的辅助类,用于帮助GLSurfaceView管理Surface。
SurfaceControl是SurfaceHolder的辅助类,用于控制Surface。
SurfaceFlinger,它是Android上的一个后台服务,同时它还是Layer的管理器,用于将多个 Layer 合成到屏幕上一起显示。
Layer,是SurfaceView中Surface的消费者,用于从Surface中消费数据。
Hardware,它是一个硬件抽象层,它可以让 SurfaceFlinger 将一些合成工作委托给显示子系统的硬件。
Screen,它是最终的显示设备,如手机或平板电脑的屏幕。
这样我就将上图中所以模块的作用向你介绍清楚了。接下来咱们来看一下从Camera中采集的视频帧是如何一步步在Android系统中流转,并最终在屏幕上显示出来的。
首先,当 Camera 采集到一帧数据后,它会直接将这个数据放入到 Surface 中。如前面所述,这个 Surface 就是一个缓冲区队列的管理者,它的作用是在不同的线程之间传递图像数据,它会将视频帧的句柄放入它所管理的 BufferQueue 中,等待消费者来取走。
之后,当我们想在Android 系统中使用OpenGL渲染视频帧时,我们会在创建SurfaceView/GLSurfaceView时构造一个SurfaceTexutre对象,并将SurfaceTexture交给Surface,这样Surface就会在BufferQueue中有数据时通知SurfaceTexture取走。
另外,SurfaceTexture也像是一座桥,它一头连着Surface,而另一头连接OpenGL。当它从 BufferQueue 中取出视频帧数据后,会将其转换为 GLES 纹理交由OpenGL处理。
那么,当SurfaceTexture将图像转成纹理交给OpenGL后,OpenGL是否会马上处理呢?实际上它有两种模式:一种是直接进行处理,这种更高效一些,它的工作方式类似于事件处理;另一种则是循环处理,即每隔一段时间就处理一次。
对于立即处理的方式大家都很好理解,这里我就不赘述了。对于循环处理模式你可以把它想像成一台发动机,它会按照屏幕的刷新帧率不断的调用Render类的Draw()方法。而在Draw()方法中会调用 OpenGLES 的API,触发Shader程序,最终利用Shader程序将图像渲染出来,而在Shader程序中,我们就可以为视频帧纹理添加各种滤镜特效了。
最终渲染的结果会被输出到 GLSurfaceView所管理的 Surface 上,等待消费者来取走。而该Surface的消费者就是Layer(这里我们要注意,图中有两个Surface,其中一个用于Camera与应用程序之间传递图像数据,而另一个用于应用程序与SurfaceFlinger服务之间传递数据,而这里指的Surface是指第二个Surface)。每一个View就会在Surface Flinger服务中有一个与之对应的Layer。
SurfaceFlinger是Android系统的一个后台服务(一个单独的进程)。它会定期的将要显示的所有Layer合并到一起(这步操作类似于Adobe PS中的图层的概念),比如在同一个应用中,有多个View要显示,此时它会算法,如果上面的View的Layer是否盖住了下面的View的Layer?如果是,那么就只显示最上层的Layer。但如果是另一种情况,上层Layer是一个小窗口,没有将下面的View全部盖住,那么SurfaceFlinger就会将它们合并成一张图送给Hardware。
而Hardware会驱动硬件层将显示内容投到Screen上,这样我们就看到了最终的结果。
那么,在这个过程中OpenGL具体是如何工作的呢?关于这方面的内容我会在下一篇文章中向你做详细的介绍。
在我看来,想在Android系统下使用好OpenGL,那你必须要先将Android显示系统的工作原理搞清楚,这样我们就有了一张完整的地图,而接下来的学习就是拿着一个“放大镜”,哪里不会就去哪里看一看,直到将整个地图的各个节细全部看清。
所以,我建议大家一定经常把这张图拿出来看一看,让自己了解自己现在自己学到哪个阶段了,下一个阶该学习什么。
实际上,早在很多年前AI技术就已经广泛的应用于我们的日常生活中了,比如人脸识别、车牌自动识别等等,而在各种美颜App、视频App中更是大量使用了AI技术。
AI技术的分支有很多,如基于传统机器学习的AI技术,基于深度学习的AI技术等,但现在大家无一例外的都选择了基于深度学习的AI技术,因为这种基于大数据自主学习的AI技术给人们带来了一个又一个惊喜,基本上只要你有足够多的数据,足够深网络,足够强的算力,它就可以给你带来足够好的结果!
基于深度学习的AI技术分为两个阶段:模型训练 和 模型使用。
模型训练是指使用大量的标注数据来训练一个深度神经网络,使其能够对输入数据进行正确的预测或分类。模型训练的过程包括以下几个步骤:
数据预处理:对原始数据进行清洗,归一化,增强,划分等操作,使其符合模型的输入要求。
网络搭建:根据任务的需求,选择合适的网络结构,如卷积神经网络,循环神经网络,生成对抗网络等,以及相应的参数,如层数,卷积核大小,激活函数,损失函数等。
网络训练:使用优化算法,如随机梯度下降,Adam等,不断更新网络的权重,使损失函数最小化,即模型的预测结果与真实标签的差距最小。
网络评估:使用一部分未参与训练的数据,如验证集或测试集,来评估模型的性能,如准确率,召回率,F1分数等,以及模型的泛化能力,即对未见过的数据的预测能力。
模型使用是指将训练好的模型部署到实际的应用场景中,对新的输入数据进行预测或分类。模型使用的过程包括以下几个步骤:
数据输入:将需要处理的数据,如图片,视频,语音等,输入到模型中,进行相应的预处理,如裁剪,缩放,灰度化等。
数据输出:模型根据输入数据,输出相应的预测或分类结果,如人脸关键点,人眼位置,人眼状态等。
数据展示:将模型的输出结果,以合适的方式,如图形,文本,声音等,展示给用户,或者进行后续的处理,如特效制作,人脸美化等。
对于AI+OpenGL特效渲染来说,主要是使用别人训练好的模型,从不是从头训练。接下来咱们来了解一下有哪些比较好用的人脸模型。
为了方便开发者使用AI技术,有许多优秀的AI人脸模型库提供了丰富的功能和接口,可供你快速地部署和使用,如Google的MLKit, MediaPipe;腾讯的TengineKit等。当然除了上面几款免费的人脸识别AI库之外,像旷视的Face++、阿里、百度、华为等都有自己的限次免费或收费产品,大家可以自己去测试比较一下。
这里我重点向你介绍一下Google的两款产品MLKit和MediaPipe。
MLKit是谷歌推出的一款移动端AI库,它针对移动设备进行了优化,可以在Android和iOS平台上使用。它主要完成两大类任务,计算机视觉和自然语言处理。
计算机视包括条形码扫描、人脸检测、人脸网格检测、文本识别、给图片加标签、对象检测和跟踪、数字墨水识别、姿势检测、自拍照细分等。
自然语言处理包括语言识别、翻译、智能回复、实体提取等。
其特点是功能齐全,接口简单,易使用。
mediapipe是谷歌推出的一款跨平台的AI库,它提供了一系列的预训练模型,如人脸检测,人脸网格,手势识别,姿态估计等,以及一套灵活的流式处理框架,可以在Android,iOS,Web,桌面等平台上使用。它与MLKit相比,更底层,效率更高。 MLKit的部分功能就是由MediaPipe来提供支持的。
MediaPipe有很多优点,首先功能丰富,MediaPipe提供了多种类型的预训练模型,可以满足不同的应用场景,如人脸处理,人体分析,物体检测等。其次适配性好,MediaPipe可以在不同的平台和设备上运行,无需修改代码,可以实现一次开发,多端部署。可扩展性强,MediaPipe提供了一套流式处理框架,开发者可以自由地组合和修改模型,以及添加自定义的处理逻辑,实现定制化的功能。不过,MediaPipe的使用相对于MLKit来说稍复杂一些。
针对于不同的AI库,我们该如何选择呢?我的建议是对于实时性和跨平台性不高的应用选择MLKit,反之则应该选择MediaPipe。
对于我们要实现的大眼特效来说,这两款AI库都可满足我们的要求,其中,MLKit可以提供122个脸部关键点,而MediaPipe则可以提供多达468个脸部关键点。这里我们选择的是MLKit,因为它使用起来更简单些。
通过前面介绍的MLKit或MediaPipe这类AI库,我们可以很轻松的定位人眼的位置。接下来咱们来了解一下实现大眼特效的算法。
关于大眼的算法有这样一篇论文, 其基本思想与我们之前讲的放大转场特效类似,我们只需以眼睛的中心点为中心,将靠近中心点的像素向外移动,即可得到放大的效果。
以上图为例,O为眼睛的中心,Radius为眼睛可以放大的最大距离,A为眼睛中的某一个点,想让眼睛放大,只需将 A 点移到 B 点,然后根据插值法,眼睛即可放大。
如何求得 B 点的坐标呢?可以使用下面的公式求得:
$$
x_d = k \cdot (x_c - x_0) + x_0 \
y_d = k \cdot (y_c - y_0) + y_0
$$
在上述公式中,$k$为缩放因子,也可以称为放大缩小的倍数。
$$
k = (1-(\frac{r}{r_{max}} - 1) ^ 2 \cdot a ) \cdot r
$$
这个公式其实就是弹簧变形公式,即弹簧压的越紧弹力越大,压的越松弹力越小。反应到眼睛上就是越靠近中心点放的越大,越远离中心点,放的越小,这样就会很自然。
公式中,$r$表示眼睛中的某个点到中心点的距离;$r_{max}$表示眼睛到中心点的最大距离;$\frac{r}{r_{max}}$表示弹簧的原始长度和最大长度的比值,它的范围是0到1,当弹簧没有受到任何外力时,它的值是0,当弹簧被拉伸到最大长度时,它的值是1。
但是,这个比值并不是我们想要的长度,因为我们想要的是距离眼睛中心越近的点放大得越多,距离眼睛中心越远的点放大得越少,而这个比值是相反的,它表示距离眼睛中心越近的点放大得越少,距离眼睛中心越远的点放大得越多。
为了解决这个问题,我们需要对这个比值做一些变换,让它符合我们的要求。首先,我们需要把这个比值减去1,这样就可以把它的范围从0到1变成-1到0,也就是把它的方向反过来,让距离眼睛中心越近的点的值越大,距离眼睛中心越远的点的值越小。这样,我们就得到了$(\frac{r}{r_{max}} −1 )$,它表示弹簧的原始长度和最大长度的差值,它的范围是-1到0,当弹簧没有受到任何外力时,它的值是-1,当弹簧被拉伸到最大长度时,它的值是0。
但我们还是希望取值的范围不要出现负数,因此我们对$(\frac{r}{r_{max}} −1 )$求平方,这样就将取值范围定位到了[0,1]这个范围内。同时,它也使得形变长度呈现一个二次曲线的变化,而不是一个线性的变化,从而形成一个更自然的放大效果。
公式中的$a$表示弹簧的形变系数,它的范围是0到1,它决定了弹簧的形变长度的最大值,也就是弹簧的最大缩放因子。当a的值越大时,弹簧的形变长度越大,也就是弹簧的缩放效果越明显,反之亦然。
这样,$(\frac{r}{r_{max}}−1)^2 \cdot a$表示它的取值范围是[0,a],当弹簧没有受到任何外力时,它的值是$a$,当弹簧被拉伸到最大长度时,它的值是0。这一项的作用是让弹簧的形变长度随着$a$的变化而变化,从而实现不同的缩放效果。
那么,$1-(\frac{r}{r_{max}}−1)^2 \cdot a$表示的就是在[1-a,1]这个范围内取值,当弹簧没有受到任何外力时,它的值是1 - a,当弹簧被拉伸到最大长度时,它的值是1。这一项的作用是让弹簧的形变长度随着r的变化而变化,从而实现不同的放大效果。
通过上面的讲解,我就将大眼特效的算法向你介绍清楚了。
上面虽然我们将公式中各个部分的含义介绍清楚了,但还有几件事儿没有交待清楚。第一个$a$该如何取值呢?一般情况下,我们通过如下公式计算$a$的值:
$$
a = 15 \cdot \frac{512 \cdot 512}{width \cdot height} / 100
$$
其中,15 表示眼睛半径的像素距离;512x512表示标准图片的大小,让它除以实际图片的宽高使得不同分辨率的图片都可以得到相同的放大效果;最后除以 100 是把$a$限定在[0,1]之间。
另外,Radius该如何设置呢?如下图所示:
Radius表示的是人眼最大的取值范围,在上图中人眼的最大范围应该是P1到P3的距离。由于左右眼可能长度还不一样,因此应该分别取左右眼的Radius,再从中取最大值。计算公式如下:
$$
R = |P_1-P_3| \
Radius = max(R_{left}, R_{right})
$$
将上成的这些点弄清楚后,我们就可以将大眼特效的Shader代码写出来了,代码如下:
1 | ... |
本文介绍了如何利用AI和OpenGLES实现大眼特效,首先我们了解了基于深度学习的AI技术,分为模型训练和模型使用。 之后我向你介绍了两个常用的AI库MLKit 和 MediaPipe,其中MLKit用在移动端,其API简单易用;而MediaPipe具用跨平台的优势,功能强大。无论哪种AI库都可以让我们轻松获取人眼坐标。接下来我详细阐述了实现大眼特效的算法,我们利用AI获取人眼坐标,再根据弹簧公式就可以很容易的实现大眼特效了。
这些成果的背后,不仅是我不断学习,不断探索,不断挑战自己的过程,更是我对教育的热爱和对未来技术发展方向的笃定。在这个过程中,我遇到了很多困难,也收获了很多快乐,今天我特别想和大家分享录课的一个感悟:日拱一卒。
首先我想说的是,录课并不是一件容易的事情,尤其是长年累月的不断输出内容。更何况我讲的基本上都是一些比较深入、复杂、新颖的课,为此我付出了大量的时间、精力和心血!
令我印象最深的是,《WebRTC源码分析》这门课。这门课是我录制的所有课程中最困难的一门课,当然也是我最引以为傲的一门课。这门课原本计划半年内录完,结果足足用了一年的时间才将其完成。
为什么这门课这么难录呢?有两方面原因,一方面WebRTC的代码量非常庞大,而且涉及到的知识点非常多,包括音视频采集,编码,传输,解码,回音消除,噪声抑制,网络适应,拥塞控制,安全加密,信令交互等等,要讲好这些知识,需要有非常深厚的功底。这就要求老师不仅理论基础扎实,而且还要有实践经验,同时对协议标准、代码都特别熟悉才行;另一方面,如何将这么复杂的系统抽丝剥茧,讲的深入浅出,通俗易懂是另一个难题。比如采集到的数据是如何交给编码器进行编码的?编码后的数据在发给对方之前是如何进行流控的?代码中是如何使用多线程的?WebRTC处理多线程方式的好处是什么,有没有更好的方法,它有什么缺陷等等,这些问题都需要你有深入的分析,有清晰的逻辑,在讲课时要有生动的例子,用简洁的语言,作恰当的比喻,我想这对于任何一个人来说都是一项巨大的挑战。
实话讲,这么庞大的一门课,想一口气吃掉它是不现实的,录制的过程中我遇到了非常多的困难和挑战,曾经焦躁过、彷徨过,沮丧过,但我从未放弃。我是如何克服这些困难的呢?我的秘诀就是 日拱一卒。意思是每天进步一点点,不断地积累,日复一日,年复一年,最终就能达到自己的目标。对于WebRTC这门课来说,虽然不能一下将想讲的内容都说清楚,但只要其中的一部分可以讲清楚时,我就先将这部分录制出来,积小胜为大胜。而录制的过程,又是一个提炼、总结的过程,很多问题都是讲着讲着就有了新的思路,然后再按照新思路去翻阅代码,验证自己的分析是否正确,这样慢慢就将之前的“小疙瘩”全解开了,从而豁然开朗,越来越理解源码作者们为什么这样做了。这样日积月累,日久年深,一门课程就被录制出来了。
我想这个方法不仅适用于录课,也适用于任何一件需要长期坚持,需要不断学习,不断创新的事情,比如写作、编程、研究、创业等等。只要你有一个明确的目标,有一个合理的计划,有一个持之以恒的态度,有一个不断反思的习惯,有一个不断改进的精神,你就能够日拱一卒,最终实现你的梦想。尤其是当下,在大环境不景气的时候,我们会遇到比以前多十倍百倍的困难。有失业的压力,有收入大幅下降的压力,有在公司反复被虐的压力……,此时如何才能不迷失、不迷茫、不彷徨呢?不如从小事儿入手,日拱一卒,积小胜为大胜,这样让自己慢慢走出低潮区,你会发现未来会更加美好!
总之,这六年是我人生中比较充实、快乐、且很有成就感的一段时光,结识了很多朋友,影响了一些同学。后面我会继续努力,日拱一卒,给大家带来更多、更好的课程。
这里顺便提一下我的新课 《OpenGL+AI实现各种酷炫特效》,它是一门讲解如何使用OpenGL进行视频渲染以及特效处理的课程。课程中讲解了Android系统的显示系统,如何从Camera采集视频数据并将它正确显示,对显示的视频做各种特效,如彩色转黑白、浮雕、素描,LUT调色,转场特效,性能优化,与AI结合实现美颜相机等。**学好这门课你就可以实现抖音中的各种特效了。
另外,细心的朋友可能会发现,到《OpenGL+AI实现各种酷炫特效》上线为止,我录制的课程中几乎包括了音视频的方方面面,在《音视频系统入门中》我讲解了音频采集,视频采集,音频编解码、视频编解码,详细分析了RTMP协议,以及如何使用它推拉流;在《FFmpeg精讲》中,我讲解了FFmpeg的工作流,移动端如何使用FFmpeg,FFmpeg滤镜,SDL播放PCM和YUV,音视频同步,并将这些知识结合到一起实现了一个播放器;《WebRTC入门与实战》讲解了如何正确的使用WebRTC,包括了三个端:Web、Android以及iOS并,并可以让它们互联互通,同时课程中还讲解了大量的WebRTC协议;《WebRTC流媒体服务器》以mediasoup为背景,讲解了如何实现高并发、大负载的网络服务器,并将mediasoup源码做了深入剖析;《WebRTC源码剖析》则将WebRTC源码详细的剖析了一遍,包括线程模型,整体架构,音频采集,视频采集,音频编解码,视频编解码,网络传输,NetEQ,拥塞控制等,以及各模块之间的关联关系,使用到的各种协议。如果你对音视频感兴趣,想提升这方面的能力,就可以去学习我的课程,相信一定会对你大有裨益的。
]]>