☰
Android WebRTC VideoFrame转Bitmap与图像识别实战
2026/10/15 1:24:37 网站建设 项目流程

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 再转 BitmapVideoFrame.getBuffer()+ YUV 转换兼容性好,可控性强手动处理 YUV,代码量大
直接用 TextureBuffer 转 BitmapTextureBuffer+ 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 转 NV215-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 逐行读取
内存泄漏未释放 VideoFrametry-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 不同识别框架的适配要点

框架输入格式注意事项
TFLiteByteBuffer / float[]注意归一化和通道顺序
ONNX Runtimefloat[] / NCHW注意维度排列
NCNNMat / float[]注意 BGR 和 RGB 区别
ML KitInputImage可直接从 Bitmap 创建

ML Kit 是最省事的,InputImage.fromBitmap(bitmap, rotation)一行搞定。但它的模型是固定的,不能自定义。如果你要用自己的模型,还是得走 TFLite 或 ONNX 这条路。

6.5 后续扩展方向

这套方案搭好之后,可以往几个方向扩展:

  • 多路流同时处理:每个流一个 VideoSink,共用一个工作线程池
  • 识别结果叠加渲染:用自定义 View 在视频上层绘制识别框
  • 录制与回放:把处理后的帧编码保存,用于后续分析
  • 端云协同:端侧做初步筛选,可疑帧上传云端做精细识别

我个人在实际操作中的体会是,这套方案的核心难点不在转换本身,而在线程管理和内存管理。转换代码网上到处都是,但能把线程模型设计对、把内存释放做干净的,不多。我踩过的坑基本都集中在release()漏调和回调线程阻塞这两件事上。你把这两个问题解决了,剩下的就是调参和优化的事。

最后再分享一个小技巧:如果你不确定转换出来的 Bitmap 是否正确,可以先把 Bitmap 保存到本地文件,用图片查看器打开看看。颜色对不对、方向对不对,一眼就能看出来。这比在代码里调试快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询