1. 项目背景与核心需求拆解
Android 端做实时视频流处理,绕不开 WebRTC 这套东西。不管是做远程协助、在线教育、视频会议,还是最近比较火的端侧图像识别,只要涉及到实时视频流的获取和处理,WebRTC 基本都是首选方案。但问题来了:WebRTC 给你的是一路VideoFrame,而图像识别模型要的是Bitmap或者ByteBuffer,中间这层转换怎么做、在哪做、怎么保证不卡顿,这里面的坑比想象中多得多。
这个项目的核心需求很明确:在 Android 端从 WebRTC 的视频流中获取每一帧图像,把VideoFrame转成Bitmap,然后交给图像识别模块去处理。听起来就三步——获取帧、转换格式、送入识别——但实际落地的时候,你会发现每一步都有讲究。
先说适用人群。如果你正在做以下事情,这篇内容应该能帮到你:
- 在 Android 上集成 WebRTC,需要从远端或本地视频流中抓帧做分析
- 做端侧图像识别,需要把 WebRTC 的
VideoFrame喂给 TFLite / ONNX / NCNN 等推理框架 - 做视频监控、远程协助中的实时画面标注、物体检测
- 需要在不影响视频通话质量的前提下,旁路获取帧数据
核心关键词就几个:WebRTC、VideoFrame、Bitmap、图像识别、Android。这几个词串起来就是一条完整的数据链路,任何一个环节出问题,整条链路就断了。
我见过不少团队在这个环节上翻车。有的是直接在 WebRTC 的回调线程里做 Bitmap 转换,结果视频卡成 PPT;有的是转换出来的 Bitmap 颜色不对,绿油油一片;还有的是帧率对不上,识别结果延迟好几秒。这些问题本质上都是对 WebRTC 的帧处理机制理解不够深导致的。
下面我会从整体设计思路开始,把这条链路拆开揉碎,把每个环节的原理、代码、坑点都讲清楚。你照着做,基本能少走两三天弯路。
2. 整体方案设计与技术选型考量
2.1 为什么选 WebRTC 作为视频源
先说说为什么用 WebRTC 而不是别的方案。Android 上获取视频帧的途径其实不少:Camera2 API 直接拿数据、MediaCodec 解码后取帧、FFmpeg 拉流解码等等。但 WebRTC 的优势在于它已经把采集、编码、传输、解码、渲染整条链路都封装好了,你不需要关心底层是用 H.264 还是 VP8,不需要处理网络抖动和丢包,拿到的就是解码后的VideoFrame。
对于图像识别场景来说,这意味着你可以直接复用 WebRTC 已经建立好的视频通道,在解码后的环节旁路取帧,而不需要额外再拉一路流或者重新解码。这个设计思路在远程协助、视频会议加 AI 分析的场景里特别常见。
但 WebRTC 也有它的脾气。它的帧回调是在特定的线程上执行的,而且帧对象是有生命周期管理的,你不能随便持有引用,更不能在回调里做耗时操作。这些约束直接决定了后面的方案设计。
2.2 VideoFrame 到 Bitmap 的转换路径选择
从VideoFrame到Bitmap,技术上有多条路可以走,我列一下常见的几种:
| 转换路径 | 核心方法 | 优点 | 缺点 |
|---|---|---|---|
| I420 转 NV21 再转 Bitmap | VideoFrame.getBuffer()+ YUV 转换 | 兼容性好,可控性强 | 手动处理 YUV,代码量大 |
| 直接用 TextureBuffer 转 Bitmap | TextureBuffer+ OpenGL 读取 | 性能好,GPU 加速 | 需要 EGL 环境,复杂度高 |
| 通过 SurfaceTexture 渲染后取帧 | SurfaceTexture+PixelCopy | 画面质量高 | 延迟大,不适合高频取帧 |
| 用 WebRTC 自带工具类 | YuvConverter+TextureBufferImpl | 官方封装,稳定 | 依赖内部 API,版本兼容需注意 |
我个人的选择是第一条路:I420 转 NV21 再转 Bitmap。原因有几个:
第一,VideoFrame的I420Buffer是最通用的格式,不管底层解码出来是什么,WebRTC 都会帮你转成 I420,你拿到的数据是稳定的。第二,YUV 到 RGB 的转换虽然要写点代码,但逻辑清晰,不依赖 OpenGL 环境,在任意线程都能跑。第三,NV21 是 Android Camera 的原生格式,转 Bitmap 有现成的高效方法,YuvImage类直接支持。
当然,如果你对性能有极致要求,比如 1080p 30fps 每帧都要转,那 OpenGL 方案确实更快。但大多数图像识别场景不需要这么高的帧率,5fps 到 10fps 足够了,CPU 转换完全扛得住。
2.3 线程模型设计
这是最容易被忽视但最致命的一环。WebRTC 的VideoSink.onFrame回调是在它内部的渲染线程上执行的,这个线程同时还负责视频渲染。如果你在这个回调里直接做 YUV 转 RGB、Bitmap 创建、甚至跑图像识别模型,那视频画面必然卡顿。
我的做法是:在onFrame回调里只做一件事——把VideoFrame的数据拷贝出来,然后丢到独立的工作线程去处理。具体来说:
onFrame回调中,调用VideoFrame.getBuffer().toI420()拿到 I420 数据- 把 I420 数据拷贝到一个字节数组里,连同宽高信息一起封装成一个任务对象
- 通过
Handler或者ExecutorService把任务投递到工作线程 - 工作线程里做 YUV 转 NV21、NV21 转 Bitmap、图像识别
- 识别结果通过回调返回主线程更新 UI
这里有个关键点:VideoFrame实现了RefCounted接口,你必须在使用完后调用release(),否则会内存泄漏。但如果你在onFrame里拷贝了数据,就可以立即release(),不需要把帧对象传到工作线程。这个细节后面会详细讲。
2.4 帧率控制策略
图像识别不需要每一帧都处理。WebRTC 视频通常是 30fps,你如果每帧都转 Bitmap 跑识别,CPU 和内存都吃不消。我的建议是根据识别需求做降频:
- 人脸检测类:5-10fps 足够
- 物体识别类:3-5fps 足够
- 文字识别类:1-2fps 足够
实现方式很简单,在onFrame回调里加一个时间戳判断,距离上次处理不足间隔就直接release()返回。比如你要 5fps,间隔就是 200ms。
private long lastProcessTime = 0; private static final long FRAME_INTERVAL_MS = 200; // 5fps @Override public void onFrame(VideoFrame frame) { long now = System.currentTimeMillis(); if (now - lastProcessTime < FRAME_INTERVAL_MS) { frame.release(); return; } lastProcessTime = now; // 继续处理... }这个简单的判断能省掉 80% 以上的无效计算,对整体性能提升非常明显。
3. 核心细节解析与实操要点
3.1 VideoFrame 的数据结构解析
要正确转换,先得搞清楚VideoFrame里到底装了什么。一个VideoFrame对象包含以下核心信息:
- Buffer:视频数据,类型可能是
I420Buffer、TextureBuffer、NV12Buffer等 - Width / Height:帧的宽高,注意这是解码后的实际尺寸,可能和采集尺寸不同
- Rotation:旋转角度,通常是 0、90、180、270
- TimestampNs:时间戳,单位纳秒
关键点在于Buffer的类型。WebRTC 在 Android 上默认使用TextureBuffer来做渲染,因为这样可以走 GPU 路径。但TextureBuffer的数据在 GPU 纹理里,CPU 读不到。所以你需要调用frame.getBuffer().toI420()来获取一份 CPU 可读的 I420 数据。
toI420()这个方法会做一次格式转换和内存拷贝,有一定开销,但这是必须的。转换后的I420Buffer包含三个平面:
- Y 平面:亮度信息,大小是 width × height
- U 平面:色度信息,大小是 (width/2) × (height/2)
- V 平面:色度信息,大小是 (width/2) × (height/2)
三个平面是分开存储的,每个都有自己的ByteBuffer和Stride(行跨度)。Stride 可能大于宽度,这是因为内存对齐的原因,处理的时候必须按 Stride 来读取,否则画面会错位。
3.2 I420 转 NV21 的完整实现
I420 和 NV21 的区别在于色度数据的排列方式:
- I420:Y 平面 + U 平面 + V 平面(三个独立平面)
- NV21:Y 平面 + VU 交错平面(两个平面,色度交错)
转换的核心就是把 I420 的 U 和 V 平面交错合并成 NV21 的 VU 平面。代码如下:
public static byte[] i420ToNv21(I420Buffer i420Buffer) { int width = i420Buffer.getWidth(); int height = i420Buffer.getHeight(); int ySize = width * height; int uvSize = width * height / 4; byte[] nv21 = new byte[ySize + uvSize * 2]; // 拷贝 Y 平面 ByteBuffer yBuffer = i420Buffer.getDataY(); int yStride = i420Buffer.getStrideY(); int index = 0; for (int row = 0; row < height; row++) { yBuffer.position(row * yStride); yBuffer.get(nv21, index, width); index += width; } // 交错拷贝 V 和 U 平面 ByteBuffer uBuffer = i420Buffer.getDataU(); ByteBuffer vBuffer = i420Buffer.getDataV(); int uStride = i420Buffer.getStrideU(); int vStride = i420Buffer.getStrideV(); int uvHeight = height / 2; int uvWidth = width / 2; index = ySize; for (int row = 0; row < uvHeight; row++) { for (int col = 0; col < uvWidth; col++) { nv21[index++] = vBuffer.get(row * vStride + col); nv21[index++] = uBuffer.get(row * uStride + col); } } return nv21; }这段代码有几个容易出错的地方:
注意:读取 Y 平面时不能直接用
yBuffer.get(nv21, 0, ySize),因为 Stride 可能大于 Width,直接读会把 padding 数据也读进去,导致画面错位。必须按行读取。
注意:NV21 的排列是 V 在前 U 在后,不是 U 在前。写反了颜色会偏蓝或偏绿。
3.3 NV21 转 Bitmap 的高效方法
拿到 NV21 数据后,转 Bitmap 就简单了,Android 自带的YuvImage类可以直接处理:
public static Bitmap nv21ToBitmap(byte[] nv21, int width, int height) { YuvImage yuvImage = new YuvImage(nv21, ImageFormat.NV21, width, height, null); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); yuvImage.compressToJpeg(new Rect(0, 0, width, height), 80, outputStream); byte[] jpegData = outputStream.toByteArray(); Bitmap bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.length); return bitmap; }这个方法的好处是稳定、兼容性好,任何 Android 版本都能跑。但缺点也很明显:它走的是 JPEG 编码再解码的路径,中间有一次压缩和解压,速度慢,而且 JPEG 是有损压缩,画质会有损失。
如果你对性能有要求,可以用RenderScript或者手写 YUV 转 RGB 的代码。手写的话核心公式是:
R = Y + 1.402 * (V - 128) G = Y - 0.344 * (U - 128) - 0.714 * (V - 128) B = Y + 1.772 * (U - 128)用这个公式逐像素计算,然后填充到Bitmap的int[]数组里,最后用Bitmap.createBitmap创建。这样没有 JPEG 编解码开销,速度快很多,但代码量大,而且要注意整数运算和浮点运算的性能差异。
3.4 旋转角度的处理
WebRTC 的VideoFrame带有Rotation信息,这个角度表示画面需要旋转多少度才是正向。如果你不做处理,识别出来的结果可能是倒着的或者侧着的。
处理方式有两种:
- 在 YUV 层面旋转:转换前先旋转 I420 数据,代码复杂但内存效率高
- 在 Bitmap 层面旋转:转成 Bitmap 后用
Matrix旋转,代码简单但多一次内存拷贝
我一般选第二种,因为代码简单不容易出错:
public static Bitmap rotateBitmap(Bitmap bitmap, int rotationDegrees) { if (rotationDegrees == 0) return bitmap; Matrix matrix = new Matrix(); matrix.postRotate(rotationDegrees); return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); }提示:旋转后的 Bitmap 如果不再使用原图,记得调用
bitmap.recycle()释放内存,否则容易 OOM。
3.5 内存管理与帧释放
这是整个流程中最容易出内存泄漏的地方。VideoFrame实现了RefCounted,每次onFrame回调拿到的帧对象都需要你负责释放。规则很简单:
- 如果你调用了
frame.getBuffer().toI420(),拿到的是一个新的I420Buffer,需要单独释放 VideoFrame本身在回调结束后如果不持有,需要调用frame.release()- 如果你把帧数据拷贝到了自己的数组里,就可以立即释放帧对象
我见过有人把VideoFrame存到一个列表里慢慢处理,结果内存暴涨直接崩溃。正确的做法永远是:在回调里拷贝数据,立即释放帧对象,后续处理用自己的数据。
@Override public void onFrame(VideoFrame frame) { // 拷贝数据 I420Buffer i420 = frame.getBuffer().toI420(); byte[] nv21 = i420ToNv21(i420); int width = i420.getWidth(); int height = i420.getHeight(); int rotation = frame.getRotation(); // 立即释放 i420.release(); frame.release(); // 投递到工作线程处理 workerHandler.post(() -> processFrame(nv21, width, height, rotation)); }这个模式我用了很多次,稳定可靠,没有出过内存问题。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖配置
先把基础环境搭好。在build.gradle里加上 WebRTC 的依赖:
dependencies { implementation 'org.webrtc:google-webrtc:1.0.32006' // 图像识别框架按需选择 implementation 'org.tensorflow:tensorflow-lite:2.14.0' }WebRTC 的版本选择有讲究。1.0.32006是一个比较稳定的版本,API 变动不大,社区资料也多。如果你用的是更新的版本,注意检查VideoFrame和I420Buffer的 API 是否有变化。
权限方面,至少需要:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />如果是处理远端流,摄像头权限可以不要,但网络权限必须有。
4.2 初始化 WebRTC 与视频轨道
WebRTC 的初始化有一套标准流程,这里不展开讲全部,只聚焦和取帧相关的部分。核心是拿到VideoTrack并添加VideoSink:
// 创建 PeerConnectionFactory PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(context) .createInitializationOptions() ); // 创建 VideoSink VideoSink videoSink = new VideoSink() { @Override public void onFrame(VideoFrame frame) { // 这里就是取帧的入口 handleVideoFrame(frame); } }; // 把 Sink 添加到 VideoTrack videoTrack.addSink(videoSink);addSink之后,每当有新的视频帧解码完成,onFrame就会被调用。注意这个回调的频率等于视频帧率,通常是 30fps 或 15fps。
4.3 帧数据处理流水线搭建
整个处理流水线我分成四个阶段,每个阶段职责单一:
阶段一:帧获取与降频(WebRTC 渲染线程)
private void handleVideoFrame(VideoFrame frame) { long now = System.currentTimeMillis(); if (now - lastProcessTime < FRAME_INTERVAL_MS) { frame.release(); return; } lastProcessTime = now; // 拷贝数据 I420Buffer i420 = frame.getBuffer().toI420(); int width = i420.getWidth(); int height = i420.getHeight(); int rotation = frame.getRotation(); byte[] nv21 = i420ToNv21(i420); i420.release(); frame.release(); // 投递到工作线程 FrameTask task = new FrameTask(nv21, width, height, rotation); workerHandler.obtainMessage(MSG_PROCESS_FRAME, task).sendToTarget(); }阶段二:格式转换(工作线程)
private void processFrame(FrameTask task) { Bitmap bitmap = nv21ToBitmap(task.nv21, task.width, task.height); if (task.rotation != 0) { Bitmap rotated = rotateBitmap(bitmap, task.rotation); if (rotated != bitmap) { bitmap.recycle(); bitmap = rotated; } } // 送入识别 runImageRecognition(bitmap); }阶段三:图像识别(工作线程或独立线程)
private void runImageRecognition(Bitmap bitmap) { // 根据你的识别框架调整输入格式 // TFLite 通常需要 ByteBuffer 或 float 数组 ByteBuffer input = convertBitmapToByteBuffer(bitmap, 224, 224); float[][] output = new float[1][NUM_CLASSES]; interpreter.run(input, output); // 处理识别结果 handleRecognitionResult(output); bitmap.recycle(); }阶段四:结果回调(主线程)
private void handleRecognitionResult(float[][] output) { mainHandler.post(() -> { // 更新 UI,绘制识别框等 updateOverlay(output); }); }这条流水线的关键在于每个阶段都在合适的线程上执行,互不阻塞。WebRTC 渲染线程只做数据拷贝,工作线程做转换和识别,主线程只更新 UI。
4.4 图像识别输入格式适配
不同的识别框架对输入格式要求不同,这里以 TFLite 为例说明转换方法:
private ByteBuffer convertBitmapToByteBuffer(Bitmap bitmap, int inputWidth, int inputHeight) { ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4 * inputWidth * inputHeight * 3); byteBuffer.order(ByteOrder.nativeOrder()); Bitmap resized = Bitmap.createScaledBitmap(bitmap, inputWidth, inputHeight, true); int[] pixels = new int[inputWidth * inputHeight]; resized.getPixels(pixels, 0, inputWidth, 0, 0, inputWidth, inputHeight); for (int pixel : pixels) { float r = ((pixel >> 16) & 0xFF) / 255.0f; float g = ((pixel >> 8) & 0xFF) / 255.0f; float b = (pixel & 0xFF) / 255.0f; byteBuffer.putFloat(r); byteBuffer.putFloat(g); byteBuffer.putFloat(b); } resized.recycle(); return byteBuffer; }这里有几个参数需要注意:
- 输入尺寸:TFLite 模型通常要求固定尺寸,比如 224×224 或 300×300,必须和模型输入层一致
- 归一化:有的模型要求 0-1 归一化,有的要求 -1 到 1,有的要求 0-255,必须看模型文档
- 通道顺序:RGB 还是 BGR,这个搞错了识别结果会完全不对
4.5 性能实测数据与调优
我在一台中端 Android 设备上做了实测,数据如下:
| 环节 | 耗时(ms) | 说明 |
|---|---|---|
| toI420 转换 | 8-12 | 取决于分辨率 |
| I420 转 NV21 | 5-8 | 纯 CPU 拷贝 |
| NV21 转 Bitmap(YuvImage) | 15-25 | 含 JPEG 编解码 |
| Bitmap 缩放 | 3-5 | 缩放到模型输入尺寸 |
| TFLite 推理(MobileNet) | 20-40 | 取决于模型复杂度 |
| 合计 | 51-90 | 单帧总耗时 |
按 5fps 处理,每帧预算 200ms,这个耗时完全在可接受范围内。如果你觉得 NV21 转 Bitmap 太慢,可以换成手写 YUV 转 RGB,能省掉 JPEG 编解码的 10-15ms。
调优建议:
- 如果识别模型输入尺寸小,可以在 YUV 层面直接做缩放,避免先转大 Bitmap 再缩小
- 如果设备支持,用 GPU 委托跑 TFLite,推理速度能提升 2-3 倍
- 工作线程用单线程就够,多线程反而增加上下文切换开销
5. 常见问题与排查技巧实录
5.1 画面颜色异常排查
问题现象:转换出来的 Bitmap 颜色偏绿、偏紫或者整体色调不对。
排查思路:
- 检查 U 和 V 是否写反了。NV21 是 V 在前 U 在后,NV12 是 U 在前 V 在后,搞混了颜色就会异常
- 检查 YUV 转 RGB 的公式系数是否正确,特别是 BT.601 和 BT.709 的区别
- 检查 Stride 处理是否正确,如果按 Width 读取而不是 Stride,画面会错位导致颜色混乱
解决方法:先用一张纯色图测试,比如纯红、纯绿、纯蓝,看转换后的颜色是否对应。如果不对,逐项检查上述三点。
5.2 内存泄漏与 OOM 排查
问题现象:运行一段时间后应用崩溃,日志显示OutOfMemoryError。
排查思路:
- 检查是否每个
VideoFrame都调用了release() - 检查
I420Buffer是否释放 - 检查 Bitmap 是否及时
recycle() - 用 Android Studio 的 Memory Profiler 观察内存曲线
常见错误:
// 错误写法:没有释放 public void onFrame(VideoFrame frame) { I420Buffer i420 = frame.getBuffer().toI420(); // 处理... // 忘记 release } // 正确写法 public void onFrame(VideoFrame frame) { I420Buffer i420 = frame.getBuffer().toI420(); try { // 处理... } finally { i420.release(); frame.release(); } }提示:用 try-finally 包裹是最稳妥的做法,即使中间抛异常也能保证释放。
5.3 视频卡顿与帧率下降
问题现象:添加了取帧逻辑后,视频画面明显卡顿,帧率从 30fps 掉到 10fps 以下。
排查思路:
- 检查是否在
onFrame回调里做了耗时操作 - 检查工作线程是否阻塞,任务队列是否堆积
- 检查是否有锁竞争
解决方法:
- 确保
onFrame里只做数据拷贝,不做转换和识别 - 工作线程用单线程,任务队列设置上限,超过就丢弃旧帧
- 降频处理,不需要每帧都处理
// 任务队列限流 private static final int MAX_QUEUE_SIZE = 3; private void postFrameTask(FrameTask task) { if (workerHandler.hasMessages(MSG_PROCESS_FRAME)) { workerHandler.removeMessages(MSG_PROCESS_FRAME); } workerHandler.obtainMessage(MSG_PROCESS_FRAME, task).sendToTarget(); }这段代码的作用是:如果队列里还有未处理的任务,直接替换掉,保证处理的永远是最新的帧。这在实时场景里比排队处理更合理。
5.4 识别结果延迟过大
问题现象:识别结果比实际画面慢好几秒。
排查思路:
- 检查任务队列是否堆积,帧处理速度跟不上帧产生速度
- 检查是否有不必要的内存拷贝
- 检查识别模型推理耗时是否过长
解决方法:
- 降频,从 30fps 降到 5fps
- 用最新的帧替换队列中的旧帧
- 优化模型,用轻量级网络或者量化模型
- 如果识别不是必须实时,可以进一步降低处理频率
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 颜色偏绿 | U/V 写反 | 检查 NV21 排列顺序 |
| 画面错位 | Stride 处理错误 | 按 Stride 逐行读取 |
| 内存泄漏 | 未释放 VideoFrame | try-finally 确保 release |
| 视频卡顿 | 回调线程做耗时操作 | 拷贝数据后投递工作线程 |
| 识别延迟 | 任务队列堆积 | 限流,用最新帧替换旧帧 |
| 画面倒置 | 未处理 Rotation | 根据 frame.getRotation() 旋转 |
| 识别不准 | 输入格式不匹配 | 检查归一化和通道顺序 |
| 崩溃闪退 | Bitmap 未回收 | 及时 recycle,复用 Bitmap |
5.6 独家避坑经验
说几个文档里不会写但实际会遇到的坑:
坑一:toI420() 在部分设备上返回 null。某些定制 ROM 或者低端设备上,TextureBuffer.toI420()可能返回 null。一定要做空判断,否则直接 NPE 崩溃。
坑二:Rotation 在本地流和远端流上表现不同。本地摄像头采集的流,Rotation 通常是 90 或 270;远端流经过传输后,Rotation 可能是 0。不要假设 Rotation 是固定值,每次都从 frame 里读。
坑三:YuvImage 的 compressToJpeg 质量参数影响速度。质量设 100 和设 80,速度差一倍。图像识别不需要高质量,设 70-80 足够了。
坑四:Bitmap.createScaledBitmap 可能返回原图。如果目标尺寸和原图一样,它直接返回原图引用。这时候你 recycle 原图会导致后续使用崩溃。判断一下返回值是否和输入是同一个对象。
坑五:工作线程的 Handler 要用 HandlerThread。不要在主线程创建 Handler 然后 post 到子线程,那样还是在主线程执行。正确做法是用HandlerThread创建带 Looper 的子线程。
HandlerThread workerThread = new HandlerThread("FrameProcessor"); workerThread.start(); Handler workerHandler = new Handler(workerThread.getLooper());这个细节很多人搞错,结果以为自己在子线程处理,实际上还是在主线程,视频照样卡。
6. 进阶优化与扩展思路
6.1 用 OpenGL 加速转换
如果你的场景对性能要求极高,比如 1080p 30fps 每帧都要处理,那 CPU 转换扛不住,必须上 GPU。核心思路是用TextureBuffer直接绑定到 OpenGL 纹理,通过 shader 做 YUV 转 RGB,然后从 framebuffer 读取像素。
这条路性能好,但复杂度高,需要管理 EGL 环境、shader 编译、纹理绑定等。如果你的团队没有 OpenGL 经验,不建议轻易尝试,维护成本很高。
6.2 多帧复用与对象池
频繁创建 Bitmap 和字节数组会给 GC 带来很大压力。可以用对象池复用:
private final ArrayDeque<byte[]> nv21Pool = new ArrayDeque<>(); private final ArrayDeque<Bitmap> bitmapPool = new ArrayDeque<>(); private byte[] obtainNv21(int size) { byte[] buf = nv21Pool.poll(); if (buf == null || buf.length < size) { buf = new byte[size]; } return buf; } private void recycleNv21(byte[] buf) { if (nv21Pool.size() < MAX_POOL_SIZE) { nv21Pool.offer(buf); } }这个优化在长时间运行场景下效果明显,GC 频率能降低一个数量级。
6.3 识别结果与视频帧的时间对齐
做视频标注的时候,识别结果需要和对应的视频帧对齐。由于处理有延迟,你拿到识别结果的时候,视频可能已经播到后面几帧了。解决办法是在帧数据里带上时间戳,识别结果也带时间戳,渲染的时候根据时间戳匹配。
class FrameTask { byte[] nv21; int width; int height; int rotation; long timestampNs; // 从 VideoFrame 带过来 }VideoFrame.getTimestampNs()可以拿到帧的原始时间戳,这个值在整条链路里保持不变,是天然的对齐依据。
6.4 不同识别框架的适配要点
| 框架 | 输入格式 | 注意事项 |
|---|---|---|
| TFLite | ByteBuffer / float[] | 注意归一化和通道顺序 |
| ONNX Runtime | float[] / NCHW | 注意维度排列 |
| NCNN | Mat / float[] | 注意 BGR 和 RGB 区别 |
| ML Kit | InputImage | 可直接从 Bitmap 创建 |
ML Kit 是最省事的,InputImage.fromBitmap(bitmap, rotation)一行搞定。但它的模型是固定的,不能自定义。如果你要用自己的模型,还是得走 TFLite 或 ONNX 这条路。
6.5 后续扩展方向
这套方案搭好之后,可以往几个方向扩展:
- 多路流同时处理:每个流一个 VideoSink,共用一个工作线程池
- 识别结果叠加渲染:用自定义 View 在视频上层绘制识别框
- 录制与回放:把处理后的帧编码保存,用于后续分析
- 端云协同:端侧做初步筛选,可疑帧上传云端做精细识别
我个人在实际操作中的体会是,这套方案的核心难点不在转换本身,而在线程管理和内存管理。转换代码网上到处都是,但能把线程模型设计对、把内存释放做干净的,不多。我踩过的坑基本都集中在release()漏调和回调线程阻塞这两件事上。你把这两个问题解决了,剩下的就是调参和优化的事。
最后再分享一个小技巧:如果你不确定转换出来的 Bitmap 是否正确,可以先把 Bitmap 保存到本地文件,用图片查看器打开看看。颜色对不对、方向对不对,一眼就能看出来。这比在代码里调试快得多。