android视频采集

视频画面的采集主要是使用各个平台提供的摄像头 API 来实现的,
在为摄像头设置了合适的参数之后,将摄像头实时采集的视频帧渲染到
屏幕上提供给用户预览,然后将该视频帧编码到一个视频文件中,其使
用的编码格式一般是 H264 。当然,最终我们还要配上音频,否则没有
音频文件的视频就成了早期的默片电影了。
本节将主要学习如何在 Android iOS 平台上利用各自平台提供的摄
像头 API ,采集出正确的视频帧并绘制到屏幕上,具体的编码将会在后
续进行讨论。
6.2.1 Android 平台的视频画面采集
1. 权限配置
要想使用 Android 平台提供的摄像头,首先必须在配置文件中添加
如下权限要求:
<uses-permission android:name="android.permission.CAMERA" />
伴随着 Android 系统的发展, Android 的摄像头 API 也已经有了非常
大的变化,现在选用的 Camera 的使用方式为设置预览纹理的形式,而不
是设置 YUV 数据回调的方式,这是因为得到纹理 ID 之后,可以很方便
地进行视频滤镜处理,并且很容易渲染到界面上。
2. 打开摄像头
Android 平台提供了打开摄像头的 API ,其函数原型如下:
public static Camera open(int cameraId)
需要传入的参数就是摄像头的 ID ,从手机的发展历史可以知道,先
有后置摄像头,然后才有前置摄像头,甚至目前已经有部分手机有了更
多的辅助摄像头,所以摄像头的 ID 排列是后置摄像头是 0 ,前置摄像头
1 ,然后才是其他的摄像头,即便是这样,我们也要使用 CameraInfo
类里面的两个常量,它们分别如下。
·CAMERA_FACING_BACK 代表后置摄像头。
·CAMERA_FACING_FRONT 代表前置摄像头。
该函数返回的就是一个摄像头的实例,如果返回的是 NULL ,或者
抛出异常(因为不同厂商所给出的返回是不一样的),则代表用户没有
授权该应用访问摄像头。
3. 配置摄像头参数 获取到该摄像头实例之后,要为该摄像头实例设置对应的参数,参
数的配置主要涉及如下两个参数。
第一个参数是预览格式,一般设置为 NV21 格式的,实际上就是
YUV420SP 的格式,即 UV interleaved (交错 UVUVUV )的存放,代码
设置如下:
List<Integer> supportedPreviewFormats = parameters.getSupportedPreviewFormats();
if (supportedPreviewFormats.contains(ImageFormat.NV21)) {
parameters.setPreviewFormat(ImageFormat.NV21);
} else {
throw new CameraParamSettingException(" 视频参数设置错误 : 设置预览图像格式异常 ");
}
上述代码先取出摄像头所支持的所有预览格式,然后判断其是否包
含我们要设定的格式,如果包含,则设置进去;如果不包含,则抛出异
常,让客户端代码进行处理。
第二是设置预览的尺寸,分辨率的尺寸一般设置为 1280×720 ,当然
对于某些应用来说,可能也会设置为 640×480 的分辨率,代码设置如
下:
List<Size> supportedPreviewSizes = parameters.getSupportedPreviewSizes();
int previewWidth = 640;// 1280
int previewHeight = 480;// 720
boolean isSupportPreviewSize = isSupportPreviewSize(
supportedPreviewSizes, previewWidth, previewHeight);
if (isSupportPreviewSize) {
parameters.setPreviewSize(previewWidth, previewHeight);
} else {
throw new CameraParamSettingException(" 视频参数设置错误 : 设置预览的尺寸异常 ");
}
上述代码会取出摄像头所支持的所有分辨率列表,然后判断要设置
的分辨率是否在支持的列表中,如果包含,则设置进去,否则抛出异
常,让客户端代码进行处理。
配置完上述参数的设置之后,就需要将该参数设置给 Camera 实例
了,代码如下:
try {
mCamera.setParameters(parameters); } catch (Exception e) {
throw new CameraParamSettingException(" 视频参数设置错误 ");
}
在宽高的设置中,细心的读者可能已经注意到了宽是 1280 (或者
640 ),高是 720 (或者 480 ),这是因为摄像头默认采集出来的视频画
面是横版的,在显示的时候,需要获取当前这个摄像头采集出来的画面
的旋转角度,那么具体的旋转角度应该如何获取呢?代码如下:
int degrees = 0;
CameraInfo info = new CameraInfo();
Camera.getCameraInfo(cameraId, info);
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
degrees = (info.orientation) % 360;
} else { // back-facing
degrees = (info.orientation + 360) % 360;
}
根据不同的摄像头取出对应的 CameraInfo ,该 CameraInfo 中的
orientation 变量表示的就是该摄像头采集到的画面的旋转角度,不过,
要想正确地旋转还需要再处理一下,如果是前置摄像头,则直接对 360
进行取模;如果是后置摄像头,则先加上 360 度再取模 360 ,从而就能得
到想要旋转的角度。得到的这个角度对于后续可以将视频帧正确地显示
到屏幕上来说是至关重要的,下面讲述摄像头的预览时就会用到该角度
参数。
4. 摄像头的预览
配置好摄像头之后,剩下的事情就是配置摄像头采集每一帧图像的
回调,并且获取到图像之后将图像渲染到屏幕上。本书的第 4 章已经讲
解过了如何通过 OpenGL ES 来渲染图像,这里先来回顾一下:首先把图
像解码为 RGBA 格式;然后将 RGBA 格式的字节数组上传到一个纹理
上;最终将该纹理渲染到屏幕上。所以这里的渲染到屏幕上也会使用
OpenGL ES 来实现。由于这里要显示的纹理是摄像头按照一定的刷新频
率( fps )来更新的,所以最终显示出来的就是我们预期的预览效果
了。
整个预览过程分为三个阶段,分别为开始预览、刷新预览与结束预
览。我们首先讲解开始预览阶段,整体流程如图 6-2 所示。
图 6-2
如图 6-2 所示,首先在 Activity 的界面层构造一个 SurfaceView 用于显
示渲染结果;然后在 Native 层用 EGL OpenGL ES 构造一个渲染线程用
于渲染该 SurfaceView ,同时在该渲染线程中生成一个纹理 ID 并传递到
Java 层; Java 层利用该纹理 ID 构造出一个 Surface-Texture ,之后再将该
SurfaceTexture 作为 Camera 的预览目标。最终调用 Camera 的开始预览方
法,这样就可以将摄像头采集到的视频帧渲染到设备屏幕上了。
但是如何让摄像头按照频率采集出来的视频帧依次进行渲染呢?答
案是在图 6-3 中构造好了 SurfaceTexture 对象之后,要为该对象设置视频
帧可用时的监听器,实际上就是当 SurfaceTexture 在可以更新的时候调用
该监听器(即当 Camera 设备采集到一帧视频帧的时候会回调该监听器方
法)。将纹理 ID 设置给摄像头的代码如下:
mCameraSurfaceTexture = new SurfaceTexture(textureId);
try {
mCamera.setPreviewTexture(mCameraSurfaceTexture);
mCameraSurfaceTexture.setOnFrameAvailableListener(frameAvailableListener);
mCamera.startPreview();
} catch (Exception e) {
throw new CameraParamSettingException(" 设置预览纹理错误 ");
} 如上所述,代码中的 frameAvailableListener 是继承自
OnFrameAvailableListener 内部类的一个实例,在该内部类中重写
onFrameAvailable 方法,在该方法中调用 Native 层的方法来渲染摄像头刚
刚捕捉的图像。调用到了 Native 层之后,将会委托到渲染线程中去调用
Java 层的 SurfaceTexture updateTexImage 方法(因为必须在 OpenGL ES
的渲染线程中才可以调用该方法,所以绕了一大圈)。更新视频帧的整
体流程如图 6-3 所示。
6-3
6-3 中,当 VideoCamera 的方法 updateTexture 执行完毕之后,就说
明摄像头采集的视频帧已经更新到 Native 层生成的纹理 ID 上了,渲染线
程就可以把该纹理 ID 渲染到界面上去了。当摄像头再次采集到一帧新视
频帧的时候,就会周而复始地执行上述过程,这样在设备屏幕上就可以
流畅地看到摄像头的预览了。
对于渲染线程的搭建以及如何将一帧纹理绘制到上层界面的知识已
经在前面章节中讲解过了,那么摄像头采集到这一帧视频帧之后是如何
进行渲染的呢?这也是接下来的重点,下面一起来看看。
前面提到过,要在渲染线程中生成一个纹理 ID ,然后传递到 Java
层,再由 Java 层构造成一个 SurfaceTexture 类型的对象,并将 Camera
PreviewCallback 设置为该 SurfaceTexture 对象。由于摄像头采集出来的视
频帧的格式是 NV21 ,即采集出来的一帧的格式是 YUV420SP
width*height 个像素点共占用了 width*height*3/2 个字节数,即每个像素
点都会有一个 Y 放到数据存储的前 width*height 个数据中,每四个像素点 共享一个 UV 放到后半部分进行交错存储。而在 OpenGL 中使用的绝大部
分纹理 ID 都是 RGBA 的格式,另外之前在讲解播放器项目的时候也曾讲
Luminance 格式,但是那里是开辟 3 个纹理 ID 来表示一张 YUV 的图
片,这里必须使用一个纹理 ID 来为 Camera 更新数据,那么应该如何将 3
Luminance 的纹理 ID 合并成一个纹理 ID 呢?幸好 OpenGL ES 的扩展
GL_OES_EGL_image_external 定义了一个纹理的扩展类型,即
GL_TEXTURE_EXTERNAL_OES ,否则整个转换过程将会非常复杂。
同时这种纹理目标对纹理的使用方式也会有一些限制,纹理绑定需要绑
定到类型 GL_TEXTURE_EXTERNAL_OES 上,而不是类型
GL_TEXTURE_2D 上,对纹理设置参数也要使用
GL_TEXTURE_EXTERNAL_OES 类型,生成纹理与设置纹理参数的代
码如下:
glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, texId);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
在实际的渲染过程中绑定纹理的代码如下:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, texId);
glUniform1i(uniformSamplers, 0);
OpenGL ES shader 中,任何需要从纹理中采样的 OpenGL ES 2.0
shader 都需要声明其对此扩展( GL_OES_EGL_image_external )的使
用,使用指令如下:
#extension GL_OES_EGL_image_external : require
这些 shader 也必须使用 samplerExternalOES 采样方式来声明纹理,其
FragmentShader 中的代码如下:
static char* GPU_FRAME_FRAGMENT_SHADER =
"#extension GL_OES_EGL_image_external : require \n"
"precision mediump float; \n"
"uniform samplerExternalOES yuvTexSampler; \n" "varying vec2 yuvTexCoords; \n"
" \n"
"void main() { \n"
" gl_FragColor = texture2D(yuvTexSampler, yuvTexCoords); \n"
"} \n";
至此,这种扩展类型的纹理 ID 从创建到设置参数,再到真正的渲染
整个过程已经处理完毕,弄清楚了这种特殊纹理 ID 的使用方法之后,接
下来再看一下具体的旋转角度问题,因为在使用摄像头的时候很容易在
这个地方踩到坑,比如手机摄像头预览的时候会出现倒立、镜像等问
题,下面就来彻底地解决这类问题。
摄像头采集出来的视频都是横屏的,比如开发者为摄像头设置的预
览大小是 640×480 ,实际上摄像头采集出来的视频帧宽是 640 ,高是
480 ,并且图片也是横向采集的。正常来讲,用户使用手机时都是竖直
方向的,所以需要旋转 90 度或者 270 度用户才可以正确地看到自己的预
览效果。而具体旋转多大角度需要在当前这颗摄像头的 CameraInfo 中获
得,不同的手机甚至是不同的系统都会不一样。并且如果是前置摄像头
的话,还需要再做一个 VFlip (假设图像是横向采集出来的所以要做竖
直翻转,如果是已经旋转过了的就要做横向翻转)用于修复镜像的问
题,下面就用实际的图片来分别看一下前置摄像头和后置摄像头的具体
渲染流程。
首先我们来看一张摄像头要实际采集的物体,如图 6-4 所示。
6-4
在使用手机的摄像头去采集这个物体时,如果是前置摄像头,那么
采集得到的图片将如图 6-5 最左边的图片所示(摄像头的 CameraInfo 中取
出来的角度是 270 度),对此,应该按照摄像头的旋转角度将图片顺时
针旋转(注意这里一定是顺时针),旋转 270 度之后将得到如图 6-5 中间
的图片,最后再进行镜像处理,得到如图 6-5 最右边的图片,最终用户
在手机屏幕中看到的预览才是预期的图像。
图 6-5
如果是后置摄像头,那么一般情况下从摄像头的 CameraInfo 中取出
来的角度是 90 度,当然这一点会根据 ROM 厂商来决定,比如 LG 厂商的
Nexus 5X 设备取出来的角度就是 270 度,不论是多少度,摄像头采集出
来的图像在旋转过该角度之后肯定会是一个正常的图像,旋转流程如图
6-6 所示。
6-6
如果是 LG 厂商的 Nexus 5X 或者 HUAWEI 厂商的 Nexus 6P 这两款设
备系统升级之后,我们对图像后置摄像头的处理将如图 6-7 所示。
6-7
那么接下来就讲解一下图像的旋转和镜像。在 OpenGL ES 中这个问
题其实就是如何来确定物体的坐标和纹理坐标,尽管在第 4 章中已经讲
解过这部分内容,而在这里由于既要做旋转又要做镜像操作,所以需要 先回顾一下物体的坐标系,如图 6-8 所示。
通过如下数组来规定物体坐标:
GLfloat squareVertices[8] = {
-1.0, -1.0, // 物体左下角
1.0 -1.0 // 物体右下角
-1.0 1.0 // 物体左上角
1.0 1.0 // 物体右上角
};
下面再回顾一下 OpenGL 的纹理坐标系,如图 6-9 所示。
6-8
6-9
然后给出不做任何旋转的纹理坐标:
GLfloat textureCoordNoRotation[8] = {
0.0 0.0 // 图像的左下角
1.0 0.0 // 图像的右下角
0.0 1.0 // 图像的左上角
1.0 1.0 // 图像的右上角
};
再给出顺时针旋转 90 度的纹理坐标,大家可以想象一下,将图 6-9
顺时针旋转 90 度,然后再把对应的左下、右下、左上、右上的坐标点写
下来,如下所示:
GLfloat textureCoords[8] = {
1.0 0.0 // 图像的右下角
1.0 1.0 // 图像的右上角
0.0 0.0 // 图像的左下角
0.0 1.0 // 图像的左上角
};
现在,再给出顺时针旋转 180 度的纹理坐标:
GLfloat textureCoords[8] = { 1.0 1.0 // 图像的右上角
0.0 1.0 // 图像的左上角
1.0 0.0 // 图像的右下角
0.0 0.0 // 图像的左下角
};
之后给出顺时针旋转 270 度的纹理坐标:
GLfloat textureCoords[8] = {
0.0 1.0 // 图像的左上角
0.0 0.0 // 图像的左下角
1.0 1.0 // 图像的右上角
1.0 0.0 // 图像的右下角
};
还记得第 4 章中讲过的计算机图像的坐标系与 OpenGL 的坐标系有什
么不同吗?它们的 y 轴坐标恰好是相反的,所以这里要对每一个纹理坐
标做一个 VFlip 的变换(即把每一个顶点的 y 值由 0 变为 1 或者由 1 变为
0 ),这样就可以得到一个正确的图像旋转了。而前置摄像头还存在镜
像的问题,因此需要对每一个纹理坐标做一个 HFlip 的变换(即把每一
个顶点的 x 值由 0 变为 1 或者由 1 变为 0 ),从而让图片在预览界面中看起
来就像在镜子中的一样。
上面的步骤其实就是将一个特殊格式( OES )的纹理 ID 经过处理和
旋转,使其变成正常格式( RGBA ),那么接下来就可以把该纹理 ID
染到屏幕上去了,但是在这里还要再啰唆一句,因为该纹理 ID 的宽和高
其实就是摄像头捕捉过来的高和宽(因为我们做了一个 90 度或者 270
的旋转),目标是要将其渲染到 SurfaceView 上面去,但是如果 Java 层为
我们提供的 SurfaceView 的宽高和处理过后的该纹理 ID 的宽高不一致,
那么这一帧图像就会出现压缩或者拉伸的问题,所以在渲染到屏幕上的
时候需要进行一个自适配,让纹理按照屏幕比例自动填充。
首先来看一下前面的纹理坐标, x 0.0 1.0 就说明要把纹理的 x
方向全都绘制到物体表面(整个 SurfaceView )上去,而如果我们只想
绘制一部分,比如中间的一半,那么就可以将 x 轴的坐标写成 0.25
0.75 ,相同的原则一样被应用到 y 轴上。那么这个 0.25 0.75 是如何出来
的呢?答案很简单,要想不被拉伸,那么 SurfaceView 的宽高比例和纹
理的宽高比例就应该是相同的。假设这一张纹理的宽为 texWidth ,纹理
的高为 texHeight 以及物体的宽为 screenWidth ,物体的高为
screenHeight ,且无论是宽还是高,都是 float 类型的,那么就可以利用下 面的公式来完成自动填充的坐标计算:
float textureAspectRatio = texHeight / texWidth;
float viewAspectRatio = screenHeight / screenWidth;
float xOffset = 0.0f;
float yOffset = 0.0f;
if(textureAspectRatio > viewAspectRatio){
// Update Y Offset
int expectedHeight = texHeight*screenWidth/texWidth+0.5f;
yOffset = (expectedHeight - screenHeight) / (2 * expectedHeight);
} else if(textureAspectRatio < viewAspectRatio){
// Update X Offset
int expectedWidth = texHeight * screenWidth / screenHeight + 0.5);
xOffset = (texWidth - expectedWidth)/(2*texWidth);
}
计算得到的 xOffset yOffset 分别用于在纹理坐标中替换掉 0.0 的位
置,利用 1.0-xOffset 以及 1.0-yOffset 来替换掉 1.0 的位置,最终将得到一
个纹理坐标矩阵如下:
GLfloat textureCoordNoRotation[8] = {
xOffset yOffset
1.0 - xOffset yOffset
xOffset 1.0 - yOffset
1.0 - yOffset 1.0 - yOffset
};
至此,摄像头预览流程就可以随着摄像头所采集的各帧图像正常地
绘制下去了,从而实现整个预览的过程。
当用户切换摄像头的时候,可以向 Native 层发送一个指令, Native
层会在渲染线程中关闭当前摄像头,然后重新打开另外一个摄像头,并
配置参数,以及设置预览的 Surface-Texture ,最后调用开始预览方法,
这样就可以切换成功,用户看到的就是摄像头切换之后的预览画面了。
当我们最终关闭预览时,首先要停止整个渲染线程,然后释放掉所
建立的 Surface-Texture ,之后再将摄像头的 PreviewCallback 设置为 null
最终关闭并且释放摄像头。整个流程代码如下:
if (mCameraSurfaceTexture != null) {
mCameraSurfaceTexture.release();
mCameraSurfaceTexture = null;
}
if (null != mCamera) { mCamera.setPreviewCallback(null);
mCamera.release();
mCamera = null;
}
至此,摄像头预览部分已经全部讲解完毕,这是十分重要的,对于
后面搭建整个录制视频的项目以及后续视频直播的项目来说,这都是最
基础的部分,所以请读者好好熟悉这一部分。
本节的代码实例在代码仓库中的 CameraPreview 项目中,运行项目
之后进入摄像头预览界面,可以看到摄像头的预览,点击右上角的切换
摄像头按钮可以进行摄像头的切换操作
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值