
做HarmonyOS原生开发的人应该都有这种感觉系统能力越往底层走能拿到的资料就越少。尤其是当你需要自己接入一个自定义的人脸识别模型又想在NDK层直接把相机帧送进模型做推理时网上能搜到的信息往往断在“Hello World”这一步。这个系列我一直在拆解在HarmonyOS NEXT上实现自定义人脸识别模型的完整路径从模型转换、特征提取到推理框架接入前几篇已经跑通了纯CPU/GPU推理。这一篇专门讲卡住很多人的那一步NDK相机预览实现。也就是说不经过Java/ArkTS层的Bitmap转换直接在C层拿到相机采集的原始帧为后续人脸检测和人脸比对提供高效的数据源。我自己踩过不少坑也看到有不少同学卡在Surface的格式、EGL环境的初始化甚至卡在CMake链接上。这篇文章会把我在HarmonyOS NEXTAPI 12也就是5.0.0(12)上做NDK相机预览的完整实现方案拆开讲清楚包括为什么走NDK、Camera Kit怎么接、XComponent怎么用、YUV帧怎么流转到模型输入、常见错误怎么排查。内容偏实战适合已经跑通过基础NDK工程、现在想把人脸识别模型真正跑起来的人。1. 整体设计与思路拆解1.1 为什么人脸识别模型的输入要优先走NDK相机预览很多刚接触HarmonyOS的人脸识别开发者会有一个惯性思维用系统相机API拍到照片转成Bitmap或者PixelMap再交给模型去推理。这套流程在Demo阶段没问题但真要做成门禁机、考勤机这种需要实时预览的场景性能瓶颈会非常明显。根本原因在于每一帧图像从Camera到推理框架之间经历了多次格式转换和内存拷贝。相机输出的通常是一帧NV12或NV21格式的YUV数据如果你先把它包装成PixelMap再转成RGBA甚至再经过一次ArkTS层的对象传递那么一帧1080P图像在单次转换上就要消耗数十毫秒。对于人脸识别这种需要连续抽帧分析的场景这个开销直接决定了你的预览帧率能不能跑到25FPS以上。所以我的方案是用Camera Kit把预览流绑定到Native Window在NDK层直接申请SurfaceBuffer拿到最原始的YUV数据然后通过EGL/OpenGL ES把YUV纹理上传到GPU或者直接做CPU侧的颜色空间转换把数据喂给自研模型。核心思想就是“数据少搬家优化留给模型”。1.2 基于Camera Kit的预览链路架构HarmonyOS NEXT上Camera Kit提供了一套完整的相机能力封装。但如果只是使用系统相机默认走的是ArkTS侧的XComponent回调每一帧都会以OH_TextureBuffer的形式传递。我们要做的是打破这条默认路径把Surfaces交给NDK层去管理。实际架构是这样的Camera Kit负责打开相机设备、配置输出能力分辨率、帧率、格式并把预览流通过XComponent的Surface传递给底层。XComponent在ArkTS侧只是一个承载Surface的容器真正的渲染逻辑在C层通过OH_NativeWindow和EGL来实现。NDK层拿到Surface后通过OH_ConsumerSurface申请Buffer读取Camera采集到的原始帧。原始帧经过预处理裁剪、缩放、归一化直接送入自研人脸检测模型与识别模型。这套链路在我自己的实测环境中从相机回调到拿到可供模型推理的RGBA数据单帧耗时能控制在5ms以内。对比走Bitmap转换的链路整体性能提升非常可观。而且好处是全部数据都留在Native侧后续接OpenVINO、MNN或者自研推理引擎都方便。2. 环境准备与NDK配置要点2.1 SDK版本与开发工具链这一篇的实现基于HarmonyOS NEXT SDK 5.0.0(12)也就是API 12。为什么强调版本因为Camera Kit在API 12这个版本上新增了不少面向NDK的接口比如OH_Camera_Manager、OH_CameraDevice等早期API版本要么没有要么行为差异很大。我使用的是DevEco Studio 5.0.0以上的版本配套的NDK版本是随SDK内置的。这里要特别提醒一点NDK不要自己去网上找独立的工具链HarmonyOS的NDK是跟SDK强绑定的必须通过DevEco Studio的SDK Manager来下载版本号一定要和SDK主版本对齐。常见的问题是“NDK版本不匹配导致链接失败”比如你SDK是12却用了老的NDK链接阶段就会报各种undefined symbol。另外如果你在Windows上开发我想额外提一句与工程化有关的经验。早期我在Windows环境下也踩过不少CMake交叉编译的坑后来为了方便调试我实际采用了Linux环境构建核心库、Windows环境做上层应用联调的方式两边共享同一套CMakeLists编译出来的动态库直接放到HarmonyOS工程里调用。这种做法虽然绕了一点路但排查原生层问题的时候会舒服很多。2.2 CMakeLists的关键配置CMakeLists的正确配置是整个NDK预览能够跑起来的基础。很多新手上来就直接照搬Android的NDK CMake写法这是不对的。HarmonyOS的NDK有一套自己的toolchain需要显式指定。我这边一个可以工作的最小配置长这样cmake_minimum_required(VERSION 3.5.0) project(facedemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(facedemo SHARED src/native_module.cpp src/camera_preview.cpp src/yuv_processor.cpp src/model_runner.cpp ) target_include_directories(facedemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include $ENV{OHOS_SDK}/native/3.2.0.5/ohos-uni-package/ohos-sdk/native/sysroot/include ) target_link_libraries(facedemo PRIVATE libace_native.z.so libcamera_ndk.z.so libnative_window.z.so libegl.z.so libGLESv3.z.so libhilog_ndk.z.so )这里有几个需要重点解释的地方。libace_native.z.so是XComponent的Native侧绑定库没有它你拿不到在C层创建NativeWindow的入口。libcamera_ndk.z.so是Camera Kit的NDK接口库负责相机设备的打开和流配置。libnative_window.z.so是HarmonyOS的NativeWindow接口负责连接Surface并获取Buffer。libegl.z.so和libGLESv3.z.so用于在Native层创建EGL环境这个环境用于把Camera帧上传为GPU纹理也用于后续的渲染。注意如果你的模型推理只在CPU侧做不涉及任何可视化渲染那么EGL的初始化其实可以跳过。但是Camera Kit在申请Buffer时如果你不通过EGL消费部分设备型号上会频繁触发Buffer超时。我在实测中发现保留一个最小的EGL环境是更稳妥的做法这个后面会细说。2.3 模块划分与Native侧代码结构NDK相机预览不是单一的文件就能搞定的你在动手写之前先要把模块规划好。我在工程里按职责拆成了四个文件后面方便单独调试native_module.cpp负责NAPI接口的注册也就是把C能力以接口形式暴露给ArkTS侧调用。camera_preview.cpp相机生命周期管理包括打开设备、创建预览流、绑定Surface、处理回调。yuv_processor.cppYUV帧的预处理。真正干活的地方负责把NV12/NV21转成RGB、做缩放和裁剪。model_runner.cpp封装自研推理引擎的调用输入是预处理后的数据输出是人脸框、关键点坐标以及特征向量。这个拆分思路在系列后续文章里会一直沿用因为人脸识别不只是“有画面就行”还要把画面里的每一帧有效地变成模型的输入。后面的特征比对、人脸注册、活体检测都要依赖这一层的清晰数据接口。3. 相机预览核心实现细节3.1 XComponent的声明与Surface绑定在HarmonyOS里XComponent是承载Native预览画面的容器。它在ArkTS侧的基本形态是这样Component export struct CameraPreview { private xComponentController: XComponentController new XComponentController(); build() { Column() { XComponent({ id: camera_preview, type: XComponentType.SURFACE, controller: this.xComponentController }) .onLoad(async () { // Surface创建完成后通知Native层开始初始化相机 this.xComponentController.setXComponentSurfaceSize(1920, 1080); nativeModule.startCamera(); }) .width(100%) .height(100%) } } }这里有个小细节type必须写成XComponentType.SURFACE如果省略不写默认值是TEXTURE虽然也能显示画面但在NDK层拿到的Surface格式语义不一样处理YUV帧时会多绕一步。另外setXComponentSurfaceSize指定的宽高最好和你期望的相机输出分辨率一致差太多的话SurfaceFlinger会做额外的缩放白白浪费性能。C那边通过OH_NativeXComponent_Create拿到OH_NativeXComponent实例后再调用OH_NativeXComponent_GetNativeWindow就能拿到OHNativeWindow。这个OHNativeWindow就是我们要绑定给Camera的Surface对象。3.2 Camera Kit打开相机并绑定预览流Camera Kit在NDK层的调用逻辑可以用下面这段伪代码概括// 1. 创建相机管理器 Camera_Manager *manager nullptr; Camera_ErrorCode ret OH_Camera_Manager_GetInstance(manager); if (ret ! CAMERA_OK) { // 处理异常 } // 2. 获取相机设备列表 Camera_DeviceStatus status; Camera_Device* cameras nullptr; uint32_t cameraCount 0; OH_Camera_Manager_GetSupportedCameras(manager, cameras, cameraCount); // 3. 创建预览输出 Camera_OutputCapability* cap nullptr; OH_Camera_Manager_GetSupportedOutputCapability(manager, cameras[0], cap); Camera_Profile previewProfile cap-previewProfiles[0]; // 优先选择1080P 30帧 int32_t format previewProfile.format; // 一般情况下是CAMERA_FORMAT_YUV_420_SP int32_t width previewProfile.size.width; int32_t height previewProfile.size.height; // 4. 创建PreviewOutput并绑定到之前获取的OHNativeWindow Camera_PreviewOutput* previewOutput nullptr; OH_Camera_PreviewOutput_Create(manager, previewProfile, window, previewOutput); // 5. 创建相机会话添加PreviewOutput并开始 Camera_CaptureSession* session nullptr; OH_Camera_CaptureSession_Create(manager, session); OH_Camera_CaptureSession_AddPreviewOutput(session, previewOutput); OH_Camera_CaptureSession_CommitConfig(session); OH_Camera_CaptureSession_Start(session);这里最容易忽略的是previewProfile.format。默认情况下HarmonyOS的PreviewOutput格式是CAMERA_FORMAT_YUV_420_SP也就是NV12或NV21不同设备支持的不一样。你一定要在拿到Profile之后把format、width、height这些参数检查一遍并且通过日志记录下来。这对接下来的YUV处理至关重要。3.3 NativeWindow获取Buffer与帧数据流转Camera输出画面到NativeWindow之后NDK层通过OH_NativeWindow_NativeWindowRequestBuffer来获取当前帧的Buffer。OHNativeWindowBuffer* buffer nullptr; int fenceFd -1; int32_t ret OH_NativeWindow_NativeWindowRequestBuffer(window, buffer, fenceFd); if (fenceFd 0) { // 等待fence信号确保Buffer写入完成 } OH_NativeWindowBuffer_Handle handle OH_NativeWindow_GetBufferHandleFromNative(buffer); // handle-virAddr 是GPU侧可访问的地址 // handle-size 是Buffer大小 // handle-format 表示像素格式这里有一个很容易踩的坑从OH_NativeWindow_GetBufferHandleFromNative拿到的virAddr是GPU内存CPU侧不一定能直接读取。如果你要在CPU侧做YUV转RGB需要先调用OH_NativeWindow_NativeWindowGetBufferAddr或者通过EGL创建一个EGLImage再映射到CPU可读地址。不同设备的表现不完全一致我在有的开发板上直接读CPU地址会得到全黑帧改成EGLImage的方式后就好了。所以稳定方案是走EGL这条路线拿到OHNativeWindowBuffer之后用eglCreateImageKHR创建EGLImage再通过glEGLImageTargetTexture2DOES把相机帧绑定到GL_TEXTURE_EXTERNAL_OES纹理上。之后你可以在Fragment Shader里对纹理做任意处理也可以调用glReadPixels把数据读回CPU内存。3.4 为什么保留最小EGL环境更稳前面多次提到EGL环境这里说清楚它的作用。Camera帧从硬件到应用层本质上是通过Graphic Buffer传递的。如果你完全不创建EGLContext而是直接在CPU侧去读Buffer地址部分图形栈实现会认为你没有正确地“消费”这个Buffer导致申请下一帧时出现Buffer排队超时表现就是预览画面卡顿、掉帧、甚至长时间黑屏。我最初在做性能对比时天真地以为既然只需要YUV数据就可以跳过EGL初始化。结果在自家测试设备上跑了二十分钟以后相机回调就停了hilog里全是BufferQueue overflow之类的错误。后来老老实实补上了EGL环境问题立刻消失。建议的EGL环境参数EGLDisplay display eglGetDisplay(EGL_DEFAULT_DISPLAY); eglInitialize(display, nullptr, nullptr); eglBindAPI(EGL_OPENGL_ES_API); EGLConfig config; EGLint configAttribs[] { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE }; eglChooseConfig(display, configAttribs, config, 1, numConfigs); EGLContext context eglCreateContext(display, config, EGL_NO_CONTEXT, contextAttribs); eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context);注意我用的是EGL_NO_SURFACE因为我们不创建渲染Surface只是想让Context存在。这属于一个比较取巧的写法但实测有效。4. YUV帧预处理与人脸识别模型输入构建4.1 NV12/NV21的格式区分人脸识别模型通常吃的是RGB或BGR输入但相机给的是YUV420。在写转换代码前必须先把PixelFormat搞清楚。NV12Y平面在前UV交叉紧密排列U、V交替也就是Y Y Y ... U V U V ...NV21Y平面在前VU交叉紧密排列V、U交替也就是Y Y Y ... V U V U ...HarmonyOS的CAMERA_FORMAT_YUV_420_SP在不同硬件平台上可能输出NV12也可能输出NV21。我在代码里通过OH_NativeWindow_GetBufferHandleFromNative返回的format字段来判断。如果这个字段不可靠部分老设备会返回UNKNOWN我还会通过一帧图像的已知特征去探测比如找一帧中色彩鲜明的区域对比U/V通道的强度分布来推断是NV12还是NV21。4.2 YUV转RGB的NEON加速版本一帧1080P的YUV图像如果用纯C逐像素转换耗时会比较可观。我实测在ARM架构上纯标量循环大约需要20~30ms这个速度对于实时人脸识别来说是不可接受的。所以必须做优化。一个简单可靠的优化方案是使用NEON intrinsics。这里给出一段核心的转换思路完整的代码比较长只列关键部分// 使用NEON指令一次处理8个Y分量 uint8x8_t y_vals vld1_u8(y_ptr); uint8x8_t u_vals vdup_n_u8(*u_ptr); uint8x8_t v_vals vdup_n_u8(*v_ptr); // 将YUV转为有符号整数并减去偏移量 int16x8_t y_s16 vreinterpretq_s16_u16(vsubl_u8(y_vals, vdup_n_u8(16))); int16x8_t u_s16 vreinterpretq_s16_u16(vsubl_u8(u_vals, vdup_n_u8(128))); int16x8_t v_s16 vreinterpretq_s16_u16(vsubl_u8(v_vals, vdup_n_u8(128))); // 计算R、G、B使用定点整数近似 // R (298 * Y 409 * V 128) 8 // G (298 * Y - 100 * U - 208 * V 128) 8 // B (298 * Y 516 * U 128) 8实际优化效果同一块1080P帧NEON版本能在4~6ms内完成转换比标量快五倍以上。如果你的设备CPU支持更高的指令集还能用vld4q_u8一次处理更多字节但NEON版本已经能满足大部分实时推理场景了。4.3 模型输入Tensor的构建与归一化人脸识别模型的输入通常是112x112或者224x224的RGB图。所以YUV转RGB之后接下来就是缩放和裁剪。最常用的方案是先从原始帧里根据人脸检测框裁剪出人脸区域再缩放到模型输入尺寸。如果只是做人脸检测没有框信息那么在模型推理前可以先做全图缩放。但在系列文章里我们已经有了一步检测所以这里是“检测框 → 裁剪 → 缩放 → 归一化”的流程。缩放我建议用双线性插值虽然比最近邻慢一点但人脸识别对面部细节比较敏感最近邻在缩小的时候容易丢失关键纹理信息。双线性插值在ARM上同样可以用NEON做优化核心思想是先把目标像素映射回源坐标然后取四个相邻像素加权平均。这套代码我封装在C侧后一帧1080P缩放到112x112大约需要1.5ms左右。最后一步是归一化。模型训练时如果用的是(x/255.0 - 0.5) / 0.5这类归一化方式那么推理前也要用完全一致的参数。这里提醒一下很多开源人脸模型用的归一化参数并不相同有些是[0,1]区间有些是[-1,1]区间你如果在模型转换阶段没对齐推理出来的人脸特征向量会和对齐过的完全不对应。我自己就在这个坑里浪费过一整天。5. 人脸识别模型接入要点5.1 自定义模型的推理流程前面的Camera链路最终产出的是112x112x3的浮点Tensor。到了这一步自研人脸识别模型就正式登场了。以我们系列里转换好的ONNX模型为例推理引擎加载模型后输入节点名通常是input形状是[1, 3, 112, 112]。要注意的是有些模型用的是CHW有些是HWCHarmonyOS的NDK侧没有自动帮你做这个转换你必须在预处理阶段就决定好数据排布。我实际使用的是CHW排布因为在做TensorRT、MNN等底层推理时CHW更接近内存布局。推理输出根据模型设计不同。如果这是一个检测模型输出通常是人脸框坐标x, y, w, h以及置信度可能还包括关键点坐标。如果这是一个识别模型输出是一个特征向量维度数百维。后续的人脸比对就是在这个向量空间里做余弦相似度计算。5.2 特征向量的比对与识别链路拿到模型输出的高维向量之后人脸识别系统的目标就从“找到脸”变成了“判断是谁”。这一步的核心是计算当前帧提取出的特征向量与底库中注册特征向量之间的相似度。我常用的方式是余弦相似度float cosine_similarity(const std::vectorfloat a, const std::vectorfloat b) { float dot 0.0f, na 0.0f, nb 0.0f; for (size_t i 0; i a.size(); i) { dot a[i] * b[i]; na a[i] * a[i]; nb b[i] * b[i]; } return dot / (std::sqrt(na) * std::sqrt(nb) 1e-6f); }对于门禁机这样的场景如果相似度超过阈值比如0.65就会判定为同一人。如果希望更严格可以使用欧氏距离或L2归一化后的内积效果都差不多。核心在于底库特征的质量而这又取决于注册时的图片质量跟本文的相机预览链路同样密切相关。5.3 CPU推理与GPU推理的取舍在HarmonyOS NEXT的设备栈上人脸识别常常需要考虑NPU或GPU加速不过很多入门设备并没有完整的NPU支持。我在工程里做了两套推理后端简单说明一下取舍依据。CPU推理的优点是兼容性好几乎不用做额外适配。缺点是在一些中低端设备上一个112x112的识别网络前向推理可能需要10~20ms检测识别两段跑下来单帧可能要到30ms以上实时性会有些紧张。GPU推理用OpenGL ES或OpenCL来做卷积加速帧率能提升不少但需要针对不同GPU做算子适配。如果你只是做个人项目或门禁机产品评估我建议先把CPU推理跑通再根据帧率瓶颈决定要不要做GPU算子。6. 常见问题与排查技巧实录6.1 黑屏或者画面不刷新这个现象在NDK相机预览初期最容易出现。排查看起来复杂其实只要按照顺序逐层排查很快就能定位。先确认XComponent有没有正确加载。可以在ArkTS侧的onLoad回调里打印日志看看是否调用到了Native层的startCamera。确认Camera_Manager创建是否成功各种Camera返回码是否等于CAMERA_OK。确认PreviewOutput创建时传入的window是否为NULL。很多情况下问题是XComponent还没完成Surface初始化就传了空window给Camera。我自己在调试初期还会遇到一种情况预览画面从无到有要等好几秒。这是因为Session的Start动作和Surface的首次Buffer申请在同一帧里竞争资源。解决办法是XComponent的onLoad回调里加一点延时等主线程空闲了再启动相机或者把相机的启动放到setTimeout里。6.2 CPU读取相机Buffer全是0这个前面提过根本原因是GPU Buffer不能直接被CPU读取。如果你非要在CPU侧读建议先用EGLImage绑定纹理然后调用glFinish强制同步再glReadPixels把数据读回来。这个方法虽然绕但是最通用。有一个细节值得注意glReadPixels读回来的RGBA数据和Camera原始Buffer在行对齐上有差异。Camera的stride通常是按照64字节对齐的而OpenGL ES的ReadPixels结果也有自己的对齐规则。你如果直接把两个数据当同一个布局去用转出来的图像会出现彩色条纹。正确做法是拿到Buffer的stride字段和width字段逐行拷贝到连续的CPU内存里再去转换。6.3 帧率不稳定偶发掉帧掉帧问题的原因非常多我这里说两个最常见的。一个是Buffer消费不及时。如果你每帧都去申请Buffer但不释放或者释放速度跟不上Camera的生产速度那必然掉帧。正确姿势是每次消费完立即调用OH_NativeWindow_NativeWindowReleaseBuffer不要缓存Buffer用于后续处理。NDK层做的是实时流处理不是离线批处理。另一个是预处理耗时过长。YUV转RGB如果没做NEON优化一帧就要二三十毫秒这等于把帧率锁死在30FPS以下甚至更低。建议在C代码的耗时关键路径上加上OH_HiLog打点把每一段耗时打出来逐个优化。好钢用在刀刃上别一上来就盲猜模型推理慢。6.4 常见错误码速查我自己在开发中遇到的最频繁的几个错误码整理成表格供参考错误码或现象对应场景解决思路CAMERA_DEVICE_NOT_FOUND打开设备失败检查是否申请了ohos.permission.CAMERA以及设备是否被占用CAMERA_SESSION_NOT_CONFIGSession未配置就Start确认CommitConfig之后才调用StartSURFACE_ERROR_INITNativeWindow绑定失败检查XComponent是否已经加载完成window是否有效BufferQueue overflowBuffer消费不及时补全EGL环境确保每帧Buffer及时ReleaseEGL_BAD_ALLOCEGL环境初始化失败检查是否重复创建EGLDisplay或未调用eglBindAPI7. 项目落地的一些经验补充前面讲了非常多技术细节最后想聊一点工程化相关的东西。如果你也是为了做一个完整的人脸识别门禁机或考勤机原型建议先不要贪心第一版就把“相机预览 人脸检测 活体判断”做扎实。模型注册、特征比对、事件上报这些可以放到第二阶段。原因是相机预览链路是整个系统的地基地基不稳后面的任何识别算法都无处发挥。我见过不少工程在NPU加速上花了很多功夫最后却发现相机帧根本没进到模型里白白浪费了调优时间。另外性能调试时别只看帧率这一个指标。你可以用OH_HiLog在每段关键路径上打上时间戳然后统计一秒钟内的平均耗时和P95耗时。帧率是平均值友好型指标一旦出现偶发的几十毫秒卡顿平均帧率未必会掉太多但实际体验已经很明显了。把P95控制在16ms以内体感上才算真正流畅。最后分享一个我自己后续想继续扩展的方向目前这一版预览链路是全帧分析的也就是说每一帧都会去跑检测网络。实际上可以做一个抽帧策略比如每秒只检测5帧检测到人脸之后再提高帧率去跟踪这样能把空闲计算资源让给识别网络或者活体检测。这个思路在门禁机这种低功耗设备上尤其值得做。下一篇文章我会展开讲人脸检测框和NMS的NDK实现以及如何把检测结果回传到ArkTS侧做UI联动。