14、视频旋转与镜像:旋转角度计算、Matrix旋转、镜像翻转、Camera旋转适配

视频渲染中,旋转和镜像是个绕不开的坑。说实话,我早期做播放器时,就被这个看似简单的问题折腾过好几回。你想想看,明明摄像头拍出来的画面是正的,放到SurfaceView上怎么就倒过来了?或者用户自拍时,镜像效果怎么处理?

这一章,我们就来彻底搞定它。

旋转角度的计算逻辑

先搞清楚一个基本概念:视频的旋转角度,通常来自两个地方。

  • 视频文件的旋转信息:MP4等容器里有个metadata叫Rotation,常见值是0、90、180、270。这是拍摄设备写入的。
  • 摄像头传感器的朝向:Android设备的前置/后置摄像头,本身就有物理方向。比如后置摄像头通常是0度,前置可能是270度。

我个人的习惯是,先读取MediaMetadataRetriever拿到旋转角度:

MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(context, uri);
String rotation = retriever.extractMetadata(
    MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
int angle = Integer.parseInt(rotation); // 0, 90, 180, 270
retriever.release();

拿到角度后,我们才能决定怎么旋转。但注意,这个角度是相对于原始录制方向的。如果视频本身是横屏拍的,但播放器是竖屏显示,那你还得考虑显示区域的宽高比。

我的经验: 不要直接拿角度去旋转纹理。先判断视频宽高和Surface宽高是否匹配。如果宽高反了,说明需要交换宽高后再旋转。我曾经在这里吃过亏,画面旋转后拉伸变形,排查了半天才发现是宽高没交换。

Matrix旋转:从纹理到屏幕

在OpenGL ES中,旋转是通过Matrix实现的。我们通常操作的是纹理坐标矩阵,而不是顶点坐标矩阵。为什么?因为顶点坐标决定了画面在屏幕上的位置,纹理坐标决定了画面内容怎么映射到顶点上。

旋转的核心代码,我一般这样写:

// 创建纹理坐标矩阵
float[] textureMatrix = new float[16];
Matrix.setIdentityM(textureMatrix, 0);

// 绕Z轴旋转,角度由视频metadata决定
Matrix.rotateM(textureMatrix, 0, angle, 0, 0, 1);

// 应用到纹理坐标
// 在顶点着色器中,用 textureMatrix * textureCoord 即可

这里有个细节:旋转中心是纹理坐标的原点(0,0),也就是左下角。如果你直接旋转,画面会绕着左下角转,而不是中心。所以旋转前需要先平移到中心,旋转完再平移回去。

我习惯封装一个工具方法:

public static float[] getRotationMatrix(int angle, float width, float height) {
    float[] matrix = new float[16];
    Matrix.setIdentityM(matrix, 0);

    // 平移到中心
    Matrix.translateM(matrix, 0, 0.5f, 0.5f, 0);
    // 旋转
    Matrix.rotateM(matrix, 0, angle, 0, 0, 1);
    // 平移回原点
    Matrix.translateM(matrix, 0, -0.5f, -0.5f, 0);

    return matrix;
}
注意: 如果你用的是SurfaceTexture,它内部已经处理了部分旋转。这时候再叠加Matrix旋转,可能会重复。我建议先打印出SurfaceTexture的transform matrix看看,再决定是否额外旋转。

镜像翻转:左右颠倒的秘密

镜像翻转,说白了就是把画面左右对调。前置摄像头自拍时,如果不做镜像,你看到的是别人视角的自己,很别扭。

实现方式很简单:把纹理坐标的x轴取反

具体做法:

// 镜像矩阵:x轴缩放为-1
float[] mirrorMatrix = new float[16];
Matrix.setIdentityM(mirrorMatrix, 0);
Matrix.scaleM(mirrorMatrix, 0, -1, 1, 1);

// 同样需要平移到中心再平移回来
Matrix.translateM(mirrorMatrix, 0, 0.5f, 0.5f, 0);
Matrix.scaleM(mirrorMatrix, 0, -1, 1, 1);
Matrix.translateM(mirrorMatrix, 0, -0.5f, -0.5f, 0);

为什么是-1?因为纹理坐标x范围是0到1,取反后变成0到-1,画面就左右颠倒了。但这样画面会跑到纹理边界外,所以需要平移回来。

嗯,这里要注意:镜像和旋转的顺序。先旋转再镜像,和先镜像再旋转,结果完全不同。我一般先做旋转,再做镜像。因为旋转是修正视频方向,镜像是修正用户视角,逻辑上分开处理更清晰。

核心原则: 旋转矩阵和镜像矩阵可以合并成一个复合矩阵。但建议分开计算,最后再相乘。这样代码可读性好,也方便调试。

Camera旋转适配:Android设备的噩梦

Android设备的摄像头方向,简直是碎片化的重灾区。不同厂商、不同Android版本,摄像头传感器的朝向都不一样。

我遇到过最坑的一次:某款国产手机,后置摄像头返回的旋转角度是90度,但实际画面是正的。查了半天,发现是厂商在驱动层做了修正,但metadata没更新。

适配Camera旋转,我总结了几个步骤:

  1. 获取摄像头传感器方向:通过CameraInfo获取。
  2. 计算预览旋转角度:根据当前屏幕方向,算出预览画面需要旋转多少度。
  3. 区分前置/后置:前置摄像头需要额外做镜像。

代码示例:

// 获取摄像头传感器方向
Camera.CameraInfo info = new Camera.CameraInfo();
Camera.getCameraInfo(cameraId, info);
int sensorOrientation = info.orientation; // 通常是90或270

// 计算预览旋转角度
int displayRotation = getWindowManager().getDefaultDisplay().getRotation();
int degrees = 0;
switch (displayRotation) {
    case Surface.ROTATION_0: degrees = 0; break;
    case Surface.ROTATION_90: degrees = 90; break;
    case Surface.ROTATION_180: degrees = 180; break;
    case Surface.ROTATION_270: degrees = 270; break;
}

int result;
if (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
    // 前置摄像头:旋转角度 = (sensorOrientation + degrees) % 360
    // 然后镜像翻转
    result = (sensorOrientation + degrees) % 360;
    // 镜像在纹理矩阵中处理
} else {
    // 后置摄像头:旋转角度 = (sensorOrientation - degrees + 360) % 360
    result = (sensorOrientation - degrees + 360) % 360;
}

这个公式我记了很久,后来干脆写了个工具类。每次新项目直接复制,省得再推导一遍。

避坑指南: 我曾经在Camera2 API下,忘记设置setTargetRotation,导致预览画面和录制画面方向不一致。如果你用Camera2,记得在CaptureRequest.Builder中设置CONTROL_AE_TARGET_FPS_RANGEJPEG_ORIENTATION。嗯,这个坑我踩了两次才记住。

知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,从视频源到最终显示,旋转和镜像经历了哪些步骤。

视频旋转与镜像处理流程 视频源 / Camera 旋转角度计算 读取metadata / 传感器方向 / 屏幕方向 Matrix 矩阵变换 旋转矩阵 → 镜像矩阵 → 复合矩阵 平移中心 → 旋转/缩放 → 平移回原点 SurfaceView 显示 关键点 • 旋转中心在纹理中心 • 前置摄像头需镜像 • 旋转与镜像顺序 • Camera2 适配 • 宽高交换判断

总结一下

视频旋转和镜像,说白了就是矩阵运算。你只要记住三点:

  • 角度来源:metadata、传感器、屏幕方向,三者结合才能算出正确角度。
  • 矩阵操作:平移→旋转/缩放→平移,这个三步走套路通用所有场景。
  • 镜像特殊处理:前置摄像头必须镜像,后置一般不需要。

我在项目中,通常会把旋转和镜像封装成一个统一的TextureTransform类。输入角度和是否镜像,输出一个16位的float矩阵。这样上层调用者只需要关心业务逻辑,不用管底层矩阵怎么算。

嗯,这一章的内容就到这里。代码不多,但背后的原理值得多琢磨。你可以在自己的项目里试试,先打印出各个阶段的矩阵值,看看每一步发生了什么变化。相信我,调试几次后,你对矩阵的理解会上一个台阶。

公众号:蓝海资料掘金营,微信deep3321