最近接手了一个工程,其中涉及人脸识别后打码的流程,然后功耗特别严重,看了一下主要是图像处理逻辑问题导致的,我会把发现的问题和解决过程记录一下。
主要问题:
- 识别模型是使用yolo实现的,最后端侧部署接口使用Bitmap,相机Preview回来的是NV21数据,但这里了用这样的方法去做数据转换:

这样转写起来方便,安卓自带就有,但性能问题很大,它会先把NV21图像通过DCT、量化、霍夫曼编码等一系列运算保存成JPEG格式数据;然后重现为Bitmap数据时又要通过IDCT、霍夫曼编码等解码流程重新解成RGB数据,多了很多本不必要的逻辑,本来YUV数据通过一个公式就可以一步到位转化为RGB数据了。
- 为了让接口输出的物体外接矩形数据和Preview窗口坐标可以直接对齐,这里使用了错误的转换做法:
因为Preview的显示方向和NV21的数据有90度的角度差,它可能不希望输出的外接矩形直接用起来和Preview没法对得上,然后就用了这样的方法旋转NV21的之前提到的步骤再生成位图:

在CPU做这种双重循环非常耗时,而且本来并没有必要,直接对最终产生的坐标做很简易的交换就可以了,根本不需要在识别前做这些事。
- 为了把Camera2接口回调过来的image数据转换为上述步骤需要的NV21数据,直接在Java层进行数据提取:

其实没有什么必要这么做,完全可以把Image传到JNI层,就能直接拿里面的NV21数据了
解决方法:
其实解决这个问题我个人认为有两种方法,一种是通过PBO直接把OpenGL ES中预览画面通过PBO直接截取就能拿到RGB数据。第二个就是把相机接口回调的Image对象通过JNI解读、转换并通过直接操作Bitmap对象的native buffer进行像素更新。
其实有一个很好用的库可以把刚刚的流程直接一部到位生成bitmap,而且主张bitmap还可以不停重复更新数据即可,这个要求可以libyuv在JNI层直接实现:
环境配置:
下载libYuv,并转换里面的cpp、CMakeLists、gradle配置到自己的工程中即可:
地址在 https://github.com/hzl123456/LibyuvDemo
另外因为要用到jnigraphics,CMakeLists要改一下,配置上要加上jnigraphics,如下:
target_link_libraries(yuvutil ${log-lib} yuv jnigraphics)
编写JNI接口:
其中extractPlane用于解读Image中每个Y、U、V每个plane的数据,然后convertAndroidMediaImageToBitmap函数通过调用libyuv中的NV21ToARGB或其他YUV格式响应的库函数转换成RGB数据,再通过android自身的AndroidBitmap_lockPixels获取像素指针把转换出来的数据复制进去,此时NV21就顺利成功转换到Bitmap了。
#include <jni.h>
#include <string>
#include "libyuv.h"
#include <android/log.h>
#include <android/bitmap.h>
#define TAG "YUV_JNI"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
struct PlaneInfo {
uint8_t* data = nullptr;
int rowStride = 0;
int pixelStride = 0;
};
// 安全提取 Plane 的底层指针与步长
static PlaneInfo extractPlane(JNIEnv* env, jobject plane) {
PlaneInfo info;
if (!plane) return info;
jclass planeCls = env->GetObjectClass(plane);
if (!planeCls) return info;
jmethodID getBuffer = env->GetMethodID(planeCls, "getBuffer", "()Ljava/nio/ByteBuffer;");
jobject buf = env->CallObjectMethod(plane, getBuffer);
if (buf) {
info.data = reinterpret_cast<uint8_t*>(env->GetDirectBufferAddress(buf));
env->DeleteLocalRef(buf);
}
jmethodID getRowStride = env->GetMethodID(planeCls, "getRowStride", "()I");
info.rowStride = env->CallIntMethod(plane, getRowStride);
jmethodID getPixelStride = env->GetMethodID(planeCls, "getPixelStride", "()I");
info.pixelStride = env->CallIntMethod(plane, getPixelStride);
env->DeleteLocalRef(planeCls);
return info;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_libyuv_util_YuvUtil_convertAndroidMediaImageToBitmap(
JNIEnv* env, jclass type, jobject image, jobject bitmap) {
if (!image || !bitmap) return JNI_FALSE;
// 1. 获取 Y/U/V 三个平面
jclass imgCls = env->GetObjectClass(image);
jmethodID getPlanes = env->GetMethodID(imgCls, "getPlanes", "()[Landroid/media/Image$Plane;");
jobjectArray planes = (jobjectArray) env->CallObjectMethod(image, getPlanes);
env->DeleteLocalRef(imgCls);
if (!planes) return JNI_FALSE;
PlaneInfo y = extractPlane(env, env->GetObjectArrayElement(planes, 0));
PlaneInfo u = extractPlane(env, env->GetObjectArrayElement(planes, 1));
PlaneInfo v = extractPlane(env, env->GetObjectArrayElement(planes, 2));
env->DeleteLocalRef(env->GetObjectArrayElement(planes, 0));
env->DeleteLocalRef(env->GetObjectArrayElement(planes, 1));
env->DeleteLocalRef(env->GetObjectArrayElement(planes, 2));
env->DeleteLocalRef(planes);
if (!y.data || !u.data || !v.data) {
LOGE("Failed to get direct buffer address from Image planes");
return JNI_FALSE;
}
// 2. 锁定 Bitmap 内存
AndroidBitmapInfo bmpInfo;
void* pixels = nullptr;
if (AndroidBitmap_getInfo(env, bitmap, &bmpInfo) < 0 ||
AndroidBitmap_lockPixels(env, bitmap, &pixels) < 0) {
LOGE("Failed to lock ARGB_8888 Bitmap");
return JNI_FALSE;
}
// 3. 根据 pixelStride 自动识别 YUV 布局并转换
int res = -1;
int w = bmpInfo.width;
int h = bmpInfo.height;
int dstStride = bmpInfo.stride;
if (u.pixelStride == 1 && v.pixelStride == 1) {
//I420 / YV12 (三平面独立)
res = libyuv::I420ToARGB(
y.data, y.rowStride,
u.data, u.rowStride,
v.data, v.rowStride,
reinterpret_cast<uint8_t*>(pixels), dstStride,
w, h);
} else if (u.pixelStride == 2) {
//NV21 (U/V 交错,V 在前)
res = libyuv::NV21ToARGB(
y.data, y.rowStride,
u.data, u.rowStride,
reinterpret_cast<uint8_t*>(pixels), dstStride,
w, h);
} else if (v.pixelStride == 2) {
//NV12 (U/V 交错,U 在前)
res = libyuv::NV12ToARGB(
y.data, y.rowStride,
v.data, v.rowStride,
reinterpret_cast<uint8_t*>(pixels), dstStride,
w, h);
} else {
LOGE("Unsupported YUV_420_888 layout: U_PS=%d, V_PS=%d", u.pixelStride, v.pixelStride);
}
// 4. 解锁 Bitmap
AndroidBitmap_unlockPixels(env, bitmap);
return (res == 0) ? JNI_TRUE : JNI_FALSE;
}
坐标转换算法更新:
由于现在送入检测的数据和preview的数据有和实际数据右转90度的角度差,因此yolo接口输出的坐标是不能直接用的,不过转换起来也很容易,用一些空间想象力和基本逻辑能力推理一下即可:
1、 因为传入的Bitmap和Preview的分辨率不同,还需要先把得到的坐标除以识别图像的宽高,得到归一化的方向向量,再乘以preview的宽高进行线性变换,量化得出这些x,y坐标应该实际在preview上的什么位置。
2、x,y坐标交换,但因为之前的y值转换到preview坐标系的x轴之后,就成了右边框距离识别框的距离,因此要保持位置准确,需要执行这个计算:preview中显示识别框的x轴起点 = preview的宽度 - 步骤1量化后的y值 - 物体的宽度。

float actualWidth = results[i].height / dectectImageSize.getHeight() * previewSize.getWidth();
float actualHeight = results[i].width / dectectImageSize.getWidth() * previewSize.getHeight();
float actualX = previewSize.getWidth() - actualWidth - results[i].y / dectectImageSize.getHeight() * previewSize.getWidth();
float actualY = results[i].x / dectectImageSize.getWidth() * previewSize.getHeight();
结果:
在展锐的低端机器上,每一帧640*480的NV21数据,从需要接近100ms的转换时间,压缩到2ms内即可,随着CPU占用时间的降低,这一流程分支带来的功耗消耗也下降下来了。