1. 从视频流到图像识别:为什么要在端侧做帧提取
做过移动端音视频或者AI推理的兄弟应该都有这个体会:把实时视频流拿来做图像识别,最纠结的往往不是模型本身,而是"帧从哪来、怎么转、转完怎么喂给识别引擎"这一整条链路。我最近在做一个端侧视觉识别的项目,需求很明确——Android端拿到WebRTC的视频流,实时抽取视频帧,转成Bitmap,再送进图像识别模块做推理。听起来就三步,但真动手做的时候,坑一个接一个。
这篇就围绕"Android WebRTC视频流源码获取帧图像,VideoFrame转Bitmap作为图像识别输入"这个主题,把我从踩坑到跑通的完整思路和代码级细节摊开讲。核心关键词就几个:WebRTC视频流、VideoFrame、Bitmap转换、YUV格式、图像识别输入。适合谁看?有Android开发基础、正在做实时视频+AI推理、或者单纯想搞明白WebRTC视频帧怎么落地成可用图像的同学。哪怕你之前没碰过WebRTC,只要跟着思路走,也能把这条链路搭起来。
先说清楚这件事的价值在哪。WebRTC本身是为实时通信设计的,它拿到的是编码后的视频流,解码后给你的是VideoFrame对象,里面装的是YUV格式的原始数据,不是我们图像识别常用的RGB Bitmap。而绝大多数图像识别框架(不管是TensorFlow Lite、ONNX Runtime还是自研的推理引擎)吃的都是Bitmap或者RGB byte数组。所以中间这个"VideoFrame → Bitmap"的转换,就是整条链路的关键卡点。转得好,帧率稳、识别准;转得糙,要么花屏、要么卡顿、要么颜色错乱导致识别率暴跌。
我实测下来,整个链路最影响性能的有三个地方:一是帧的获取时机(在哪个回调里拿VideoFrame),二是YUV到RGB的转换方式(用Java层还是Native层),三是帧的采样策略(不是每一帧都要转,识别也不需要那么高的帧率)。下面我会把这三点拆开揉碎讲。
2. 整体链路设计与方案选型思路
2.1 为什么不能直接拿WebRTC的渲染帧
很多人第一反应是:WebRTC不是有SurfaceViewRenderer吗,我直接从渲染的Surface里截图不就行了?这个思路我试过,能用,但问题很大。截图走的是PixelCopy或者Canvas绘制,本质上是把已经渲染到屏幕上的内容再读回来,一来一回多了两次GPU到CPU的数据拷贝,延迟高、帧率低,而且截图拿到的分辨率受限于View的尺寸,不是视频流的原始分辨率。做识别的话,分辨率被压缩过,小目标直接糊掉。
正确的做法是从WebRTC的视频数据管道里直接拿VideoFrame。WebRTC在解码后会通过VideoSink接口回调onFrame(VideoFrame frame),这个frame里就是解码后的原始YUV数据,分辨率是视频流的真实分辨率,没有任何屏幕相关的损耗。这是最干净的数据源。
2.2 VideoFrame里到底装了什么
要转Bitmap,先得搞清楚VideoFrame的结构。WebRTC的VideoFrame主要包含三部分:Buffer(数据本体)、VideoRotation(旋转角度)、timestampNs(时间戳)。数据本体在Android上通常是I420Buffer,也就是YUV420 Planar格式,三个平面:Y平面(亮度)、U平面(色度)、V平面(色度)。Y平面是全分辨率,U和V是宽高各减半。
这里有个容易忽略的点:VideoFrame可能带旋转信息。手机摄像头采集的视频,很多时候是横着存的,靠VideoRotation标记来告诉播放器怎么转。如果你转换的时候不管这个旋转角度,转出来的Bitmap就是歪的,识别模型看到歪的图,准确率直接崩。所以转换时必须把rotation考虑进去,该旋转的旋转。
2.3 转换方案的三条路线对比
YUV转RGB/Bitmap,业界常见三条路线,我列个表对比一下:
| 方案 | 实现位置 | 性能 | 开发难度 | 适用场景 |
|---|---|---|---|---|
| Java层逐像素转换 | Java | 差 | 低 | 调试、低频抽帧 |
| 原生YUV_420_888 + RenderScript | Java+RS | 中 | 中 | 已废弃,不推荐 |
| Native层libyuv转换 | C++ | 优 | 高 | 生产环境、高帧率 |
| 原生Image转Bitmap工具 | Java | 中 | 低 | 快速验证 |
我最终选的是Native层libyuv + JNI回调的方案。原因很直接:Java层逐像素转换,一帧1080P的图要循环两百多万次,实测单帧耗时在80ms以上,根本扛不住实时。libyuv是Google自家的YUV处理库,底层有NEON指令集优化,同样一帧1080P转换只要5到10ms,差了将近十倍。虽然要写JNI、要编译so库,麻烦一点,但生产环境这个性能差距是必须的。
不过如果你只是快速验证功能,我建议先用Java层的简单方案跑通链路,确认识别效果没问题,再换成Native优化。别一上来就啃JNI,容易卡在编译环境上出不来。
2.4 抽帧策略:不是每帧都要转
还有个设计上的关键决策:抽帧。WebRTC视频流一般是25到30帧每秒,但图像识别根本不需要这么高的频率。你想想,一秒识别30次,模型推理本身可能就要几十毫秒,根本来不及,而且纯属浪费算力。
我的策略是按时间间隔抽帧,比如每200ms抽一帧,也就是5fps。这个频率对大多数识别场景(人脸、物体、手势)都够用了。实现上用一个时间戳判断,距离上一帧处理超过阈值才转Bitmap送识别,否则直接丢弃。这样既保证了识别的实时性,又不会把CPU和GPU打满。
提示:抽帧阈值要根据你的识别模型推理耗时来定。如果模型推理要100ms,那抽帧间隔至少设150ms以上,否则帧会堆积,内存暴涨。
3. 核心细节解析与实操要点
3.1 VideoSink的正确接入姿势
拿到VideoFrame的第一步,是让WebRTC把帧回调给你。你需要实现org.webrtc.VideoSink接口,核心就是onFrame方法:
public class FrameCapturer implements VideoSink { private volatile long lastProcessTime = 0; private static final long FRAME_INTERVAL_MS = 200; @Override public void onFrame(VideoFrame frame) { long now = System.currentTimeMillis(); if (now - lastProcessTime < FRAME_INTERVAL_MS) { return; // 抽帧,丢弃 } lastProcessTime = now; // 处理这一帧 processFrame(frame); } }然后把你的VideoSink加到VideoTrack上:
videoTrack.addSink(frameCapturer);这里有个大坑:onFrame是在WebRTC的内部线程回调的,不是主线程。你在这个方法里做耗时操作(比如转换、识别),会阻塞WebRTC的视频管道,导致整个通话卡顿甚至丢帧。所以onFrame里只做最轻量的事——判断抽帧、把frame引用存下来,真正的转换和识别丢到独立的工作线程去做。
但这里又有个问题:VideoFrame是WebRTC管理的对象,你如果直接把它传到别的线程异步处理,可能你还没处理完,WebRTC就把它回收了,导致野指针崩溃。正确做法是在onFrame里立刻做一次retain,处理完再release:
@Override public void onFrame(VideoFrame frame) { if (shouldSkip()) return; frame.retain(); // 引用计数+1 workerThread.post(() -> { try { processFrame(frame); } finally { frame.release(); // 引用计数-1 } }); }这个retain/release的引用计数机制,是WebRTC管理Native内存的核心,忘了release就是内存泄漏,忘了retain就是崩溃。我在这上面栽过跟头,日志里全是native crash,排查了半天才发现是frame被提前回收了。
3.2 YUV数据平面的读取细节
拿到frame后,要读它的数据。VideoFrame.getBuffer()返回的是VideoFrame.Buffer,实际类型通常是VideoFrame.I420Buffer。读取三个平面的代码如下:
VideoFrame.I420Buffer i420 = frame.getBuffer().toI420(); int width = i420.getWidth(); int height = i420.getHeight(); ByteBuffer yBuffer = i420.getDataY(); ByteBuffer uBuffer = i420.getDataU(); ByteBuffer vBuffer = i420.getDataV(); int yStride = i420.getStrideY(); int uStride = i420.getStrideU(); int vStride = i420.getStrideV();注意这里的**stride(步长)**概念。stride是每一行数据在内存里的实际字节数,它可能大于width。比如width是1080,但为了内存对齐,stride可能是1088。如果你读数据的时候直接用width当行宽,就会读到错位的数据,图像会出现斜条纹。所以转换时一定要用stride,不能用width。
还有个细节:toI420()这个调用,如果buffer本来就是I420,它直接返回;如果是其他格式(比如NV12、NV21),它会做一次格式转换。这个转换是有开销的,所以如果你的视频源固定是某种格式,可以针对性优化,避免多余的转换。
3.3 旋转角度的处理
前面提到的旋转问题,处理方式有两种。一种是在YUV转RGB之前,先把YUV数据按角度旋转;另一种是转成Bitmap之后,用Matrix旋转Bitmap。我推荐在Native层转换时一并处理旋转,因为libyuv本身就提供了旋转的接口(I420Rotate),一次搞定,不用多一次Bitmap的内存拷贝。
旋转角度从frame.getRotation()拿,返回的是VideoRotation枚举,对应0、90、180、270度。转换时根据角度决定输出Bitmap的宽高是否要对调:
int rotation = frame.getRotation(); int outWidth = (rotation == 90 || rotation == 270) ? height : width; int outHeight = (rotation == 90 || rotation == 270) ? width : height;这个宽高对调很容易漏,漏了的话Bitmap要么被拉伸变形,要么直接越界崩溃。
3.4 转换后的Bitmap格式选择
转出来的Bitmap用什么格式?ARGB_8888还是RGB_565?我的建议是看识别模型的要求。大多数模型要的是RGB三通道,ARGB_8888多了一个Alpha通道,虽然浪费25%的内存,但兼容性最好,几乎所有识别框架都能直接吃。RGB_565省内存,但颜色精度低,对颜色敏感的识别任务(比如红绿灯识别)会有影响。
如果模型明确要RGB byte数组,其实可以跳过Bitmap这一步,直接在Native层把YUV转成RGB byte[],省掉Bitmap对象的创建开销。Bitmap的创建和GC在频繁抽帧的场景下也是不小的负担。我现在的做法是:Native层转出RGB byte[],需要显示的时候再转Bitmap,识别直接用byte[]。
4. 实操过程与核心环节实现
4.1 环境准备与依赖配置
先把WebRTC的依赖加上。我用的是官方预编译的aar,在build.gradle里:
dependencies { implementation 'org.webrtc:google-webrtc:1.0.32006' }版本号选你项目对应的,不同版本API略有差异。libyuv的话,如果你不想自己编译,可以用一些开源项目里已经编译好的so,但要注意架构兼容(armeabi-v7a、arm64-v8a、x86_64都要有)。自己编译libyuv也不难,用NDK的CMake就能搞定,后面我会说。
权限方面,视频流场景一般需要CAMERA和RECORD_AUDIO,但如果你只是接收远端流做识别,其实不需要摄像头权限,只要网络权限就行。这点很多人会搞混,以为做视频就一定要摄像头权限。
4.2 Native层libyuv转换的完整实现
先写JNI接口。Java层声明:
public class YuvConverter { static { System.loadLibrary("yuvconverter"); } // 返回RGB byte数组 public static native byte[] i420ToRgb(byte[] y, byte[] u, byte[] v, int width, int height, int yStride, int uStride, int vStride, int rotation); }Native层实现(C++):
#include <jni.h> #include <libyuv.h> #include <vector> extern "C" JNIEXPORT jbyteArray JNICALL Java_com_example_YuvConverter_i420ToRgb( JNIEnv* env, jclass, jbyteArray yArr, jbyteArray uArr, jbyteArray vArr, jint width, jint height, jint yStride, jint uStride, jint vStride, jint rotation) { jbyte* y = env->GetByteArrayElements(yArr, nullptr); jbyte* u = env->GetByteArrayElements(uArr, nullptr); jbyte* v = env->GetByteArrayElements(vArr, nullptr); // 先旋转(如果需要) std::vector<uint8_t> rotatedY, rotatedU, rotatedV; int outWidth = width, outHeight = height; uint8_t* srcY = (uint8_t*)y; uint8_t* srcU = (uint8_t*)u; uint8_t* srcV = (uint8_t*)v; int srcYStride = yStride, srcUStride = uStride, srcVStride = vStride; if (rotation != 0) { if (rotation == 90 || rotation == 270) { outWidth = height; outHeight = width; } rotatedY.resize(outWidth * outHeight); rotatedU.resize((outWidth/2) * (outHeight/2)); rotatedV.resize((outWidth/2) * (outHeight/2)); libyuv::I420Rotate(srcY, srcYStride, srcU, srcUStride, srcV, srcVStride, rotatedY.data(), outWidth, rotatedU.data(), outWidth/2, rotatedV.data(), outWidth/2, width, height, (libyuv::RotationMode)rotation); srcY = rotatedY.data(); srcU = rotatedU.data(); srcV = rotatedV.data(); srcYStride = outWidth; srcUStride = outWidth/2; srcVStride = outWidth/2; } // I420转RGB24 std::vector<uint8_t> rgb(outWidth * outHeight * 3); libyuv::I420ToRGB24(srcY, srcYStride, srcU, srcUStride, srcV, srcVStride, rgb.data(), outWidth * 3, outWidth, outHeight); jbyteArray result = env->NewByteArray(rgb.size()); env->SetByteArrayRegion(result, 0, rgb.size(), (jbyte*)rgb.data()); env->ReleaseByteArrayElements(yArr, y, JNI_ABORT); env->ReleaseByteArrayElements(uArr, u, JNI_ABORT); env->ReleaseByteArrayElements(vArr, v, JNI_ABORT); return result; }这里有几个关键点。第一,I420Rotate的旋转模式枚举值,libyuv里kRotate0=0, kRotate90=90, kRotate180=180, kRotate270=270,正好和WebRTC的VideoRotation对应,可以直接强转。第二,旋转后的stride就是新的宽度,因为libyuv输出是紧凑排列的。第三,I420ToRGB24输出的是RGB24(每像素3字节),不是RGBA,喂给识别模型时要注意通道顺序。
4.3 Java层调用与Bitmap生成
Native转出RGB byte[]后,Java层生成Bitmap:
public Bitmap convertToBitmap(VideoFrame frame) { VideoFrame.I420Buffer i420 = frame.getBuffer().toI420(); int width = i420.getWidth(); int height = i420.getHeight(); byte[] y = bufferToBytes(i420.getDataY(), i420.getStrideY() * height); byte[] u = bufferToBytes(i420.getDataU(), i420.getStrideU() * (height / 2)); byte[] v = bufferToBytes(i420.getDataV(), i420.getStrideV() * (height / 2)); int rotation = frame.getRotation(); byte[] rgb = YuvConverter.i420ToRgb(y, u, v, width, height, i420.getStrideY(), i420.getStrideU(), i420.getStrideV(), rotation); int outWidth = (rotation == 90 || rotation == 270) ? height : width; int outHeight = (rotation == 90 || rotation == 270) ? width : height; Bitmap bitmap = Bitmap.createBitmap(outWidth, outHeight, Bitmap.Config.ARGB_8888); // RGB24转ARGB_8888 int[] pixels = new int[outWidth * outHeight]; for (int i = 0; i < pixels.length; i++) { int r = rgb[i * 3] & 0xFF; int g = rgb[i * 3 + 1] & 0xFF; int b = rgb[i * 3 + 2] & 0xFF; pixels[i] = 0xFF000000 | (r << 16) | (g << 8) | b; } bitmap.setPixels(pixels, 0, outWidth, 0, 0, outWidth, outHeight); i420.release(); return bitmap; }bufferToBytes就是把ByteBuffer里的数据读成byte数组,注意只读有效数据部分(stride * height),别把整个buffer都读了。
注意:
setPixels这个循环在Java层做,一帧1080P又是两百万次循环,性能又下来了。所以更彻底的做法是Native层直接输出ARGB_8888的int数组,Java层直接setPixels,省掉这个循环。我上面这么写是为了逻辑清晰,生产环境建议把RGB24转ARGB这一步也放进Native。
4.4 抽帧与识别线程的协同
把上面的东西串起来,完整的处理流程是这样的:
private final ExecutorService worker = Executors.newSingleThreadExecutor(); private volatile long lastTime = 0; @Override public void onFrame(VideoFrame frame) { long now = System.currentTimeMillis(); if (now - lastTime < 200) return; lastTime = now; frame.retain(); worker.execute(() -> { try { Bitmap bmp = convertToBitmap(frame); // 送识别 recognizer.recognize(bmp, result -> { // 回调结果,注意线程切换 mainHandler.post(() -> updateUI(result)); }); bmp.recycle(); // 及时回收 } catch (Exception e) { Log.e(TAG, "frame process error", e); } finally { frame.release(); } }); }用单线程的ExecutorService,保证帧是串行处理的,不会出现多帧并发导致的内存峰值。bmp.recycle()一定要调,Bitmap占的是Native内存,不主动回收,GC压力很大。
4.5 编译libyuv的CMake配置
如果你要自己编译libyuv,CMakeLists大概长这样:
cmake_minimum_required(VERSION 3.4.1) project(yuvconverter) add_library(yuvconverter SHARED yuv_converter.cpp libyuv/source/*.cc) include_directories(libyuv/include) find_library(log-lib log) target_link_libraries(yuvconverter ${log-lib})libyuv的源码直接从官方仓库拉,把source目录下的cc文件都加进来。编译时记得开NEON优化,在build.gradle的ndk配置里加上-DANDROID_ARM_NEON=ON。不开NEON的话,性能会打对折。
5. 常见问题与排查技巧实录
5.1 图像颜色错乱、发绿发紫
这是最常见的坑,八成是YUV格式搞错了。I420、NV12、NV21这三种格式长得像但排列不同。I420是Y、U、V三个独立平面;NV12是Y平面加一个UV交错平面(U在前);NV21是Y平面加VU交错平面(V在前)。如果你把NV21当I420读,颜色必然错乱。
排查方法:先确认frame.getBuffer()的实际类型,打印它的class名。如果是I420Buffer就直接读;如果是NV12Buffer或NV21Buffer,要么先toI420()转换,要么在Native层用对应的libyuv接口(NV12ToRGB24、NV21ToRGB24)。
还有个可能是U和V平面搞反了。I420是U在前V在后,如果你读反了,图像会偏色。这个用一张纯色测试图就能验证。
5.2 图像出现斜条纹或错位
基本可以断定是stride没用对。前面强调过,stride可能大于width。如果你读Y平面时按width逐行读,但实际stride是width+padding,就会错位。解决办法就是老老实实用getStrideY()、getStrideU()、getStrideV(),别自己算。
5.3 内存泄漏与native crash
前面说的retain/release不配对是重灾区。另外I420Buffer用完也要release。我建议在转换方法里用try-finally,确保release一定执行。日志里如果看到nativeRelease相关的crash,或者内存持续上涨不降,就往这个方向查。
还有个隐蔽的坑:VideoFrame的toI420()如果做了格式转换,返回的是一个新的buffer,这个新buffer也需要release,别只release原始的。
5.4 帧率上不去、识别卡顿
先定位瓶颈在哪。在转换前后、识别前后打时间戳,看哪一步耗时最长。常见情况:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 转换耗时>50ms | 用了Java层逐像素 | 换libyuv Native |
| 识别耗时>100ms | 模型太大或输入分辨率太高 | 降分辨率、换轻量模型 |
| 整体卡顿 | 抽帧太频繁 | 加大抽帧间隔 |
| 内存抖动 | Bitmap频繁创建回收 | 复用Bitmap、用inBitmap |
我实测的一个优化点:降低送入识别的分辨率。视频流可能是1080P,但识别模型输入可能只要224x224。与其转完1080P的Bitmap再缩放,不如在Native层转换时就缩放到目标尺寸,libyuv的I420Scale就是干这个的,省掉一次大图转换和一次缩放。
5.5 旋转后图像还是歪的
检查两点:一是frame.getRotation()拿到的值有没有正确传到Native;二是Native里旋转后的宽高有没有对调。还有个特殊情况,前置摄像头可能有镜像问题,WebRTC的rotation不一定包含镜像信息,如果发现前置摄像头图像左右反了,需要额外做一次水平翻转。
5.6 常见问题速查表
| 问题 | 排查第一步 | 高频原因 |
|---|---|---|
| 颜色错乱 | 打印buffer类型 | YUV格式不匹配 |
| 斜条纹 | 检查stride | 用width代替了stride |
| 崩溃 | 查retain/release | 引用计数不配对 |
| 卡顿 | 打时间戳 | 转换或识别耗时过长 |
| 图像歪 | 检查rotation | 旋转角度未处理 |
| 内存涨 | dump内存 | Bitmap或buffer未释放 |
6. 性能优化与进阶玩法
6.1 零拷贝的优化思路
上面这套流程,数据从Native的YUV到Native的RGB,再到Java的Bitmap,中间有几次内存拷贝。极致优化的话,可以做到零拷贝:Native层直接操作WebRTC的buffer指针,转完RGB后直接创建一个DirectByteBuffer给Java,Java层用Bitmap.copyPixelsFromBuffer直接填充,省掉setPixels的循环。
再进一步,如果识别引擎支持直接吃Native内存指针(很多C++推理引擎都支持),那连Bitmap都不用创建,Native转完RGB直接喂给推理引擎,全程不碰Java堆。这个方案性能最好,但开发复杂度也最高,适合对延迟极度敏感的场景。
6.2 用GPU做YUV转换
CPU转YUV再快也是占CPU的。如果设备GPU有余力,可以用OpenGL ES做YUV到RGB的转换,把YUV三个平面作为纹理上传,用shader做转换,输出到FBO,再读回。GPU转换的优势是几乎不占CPU,而且可以和渲染管线复用。缺点是读回(glReadPixels)本身也有开销,而且代码复杂度高。我的经验是:如果CPU转换已经能满足帧率要求,就别折腾GPU;如果CPU是瓶颈且GPU空闲,再考虑。
6.3 多帧并行与流水线
单线程串行处理虽然稳,但吞吐有限。如果识别模型推理慢,可以考虑流水线:一个线程专门做YUV转RGB,一个线程专门做识别,中间用有界队列连接。这样转换和识别可以重叠,吞吐能提升接近一倍。但要注意队列不能无界,否则帧堆积内存爆炸,用ArrayBlockingQueue设个容量(比如2),满了就丢帧。
6.4 识别结果的时序对齐
做视频识别,往往需要把识别结果和视频帧对应起来。因为抽帧和识别都有延迟,你拿到的识别结果对应的可能是几百毫秒前的画面。如果业务对时序敏感(比如追踪),需要把帧的时间戳一路传下去,识别结果带上原始时间戳,做对齐。这个在VideoFrame.getTimestampNs()里能拿到,别忘了传。
7. 我踩过的几个真实坑
说几个文档里不会写、但实际一定会遇到的坑。
第一个是WebRTC版本差异。不同版本的VideoFrameAPI有变化,比如早期版本getBuffer()返回的类型和现在不一样,toI420()的行为也可能不同。我升级WebRTC版本后,原来跑通的代码突然花屏,查了半天是buffer类型变了。所以升级依赖后,转换这块一定要重新测。
第二个是线程安全问题。VideoSink.onFrame的回调线程,不同WebRTC版本可能不一样,有的是 signaling thread,有的是专门的video thread。如果你在onFrame里直接操作UI或者共享状态,一定要加锁或者切线程。我遇到过一次偶发的ConcurrentModificationException,就是没注意线程。
第三个是低端机性能。在旗舰机上跑得飞起的方案,到低端机上可能直接卡死。低端机CPU弱,libyuv的NEON优化虽然好,但内存带宽也受限。这种设备上,抽帧间隔要加大,分辨率要降,甚至要考虑跳过高频识别,只做低频检测。
第四个是Bitmap内存。Android的Bitmap在API 26之后移到了Native堆,但计数还是算在Java堆的。频繁创建大Bitmap,很容易触发GC甚至OOM。用inBitmap复用或者用BitmapPool是个好习惯。
8. 后续可以怎么扩展
这套链路跑通之后,能玩的花样其实很多。比如把识别结果叠加回视频流,做实时标注;比如多路视频流同时抽帧识别,做多路监控;比如把抽出来的帧存下来做离线训练数据。核心的"VideoFrame转Bitmap"这一步打通了,上面这些都是加业务逻辑的事。
我个人在实际操作中的体会是,这条链路最难的不是某一行代码,而是对YUV格式和内存管理的理解。把这两个搞明白了,剩下的都是体力活。刚开始别追求性能极致,先用最简单的Java方案把链路跑通,看到识别结果出来了,再一步步优化。上来就啃JNI和libyuv,很容易在编译环境上耗掉耐心。
最后分享一个小技巧:调试YUV转换的时候,把转出来的Bitmap存成PNG文件,用图片查看器打开看颜色对不对,比在手机上看直观多了。颜色对了,格式就对了;颜色不对,对着PNG调格式,效率高很多。