Android Camera HAL实现windows摄像头显示:从黑屏到彩色照片的完整攻坚

前言

最近在做Android虚拟摄像头开发,需要实现一个Camera HAL服务,将windows客户端的摄像头数据写入共享内存,再将共享内存中的YUV数据展示到相机预览和拍照功能中。过程中踩了不少坑,从最初的黑屏、到预览闪烁、再到拍照绿屏,最后才搞定。这里把整个过程记录下来,希望能帮到有类似需求的朋友。

项目背景

我的场景是这样的:

  • 宿主机通过共享内存传递NV12格式的YUV数据(1280x720)
  • Android系统需要读取这些数据,展示在相机预览中
  • 支持拍照功能,照片可能需要不同分辨率(比如1920x1080)

技术栈:

  • Android Camera HAL 3.2
  • 共享内存通信
  • YUV/NV12格式处理

第一个大坑:预览黑屏

问题表现

实现了基本的HAL框架后,打开相机应用,预览界面一片漆黑。

排查过程

首先确认数据源没问题,在C++代码里打印共享内存的数据:

cpp 复制代码
ALOGE("Shared memory Y[0-7]: %02x %02x %02x %02x", 
      yPlane[0], yPlane[1], yPlane[2], yPlane[3]);

发现数据是正常的,说明读取没问题。

接着检查写入GraphicBuffer的过程,这里用到了Android的GraphicBufferMapper

cpp 复制代码
android_ycbcr ycbcr;
err = mapper.lockYCbCr(importedHandle, usage, bounds, &ycbcr);

打印ycbcr的各个字段,发现问题了:

复制代码
ystride=1920, cstride=1920, chroma_step=2
cb=0x..., cr=0x...

关键发现ycbcr.cbycbcr.cr不是独立的buffer,而是指向同一块内存的不同位置。这是NV12格式的特点------UV分量是交错存储的。

最初我是这样写的:

cpp 复制代码
// 错误做法
memcpy(ycbcr.cb, srcUV, width * height / 2);

这样写会导致只有cb被填充,cr还是空的。

解决方案

正确的做法是一次性复制整个UV平面(因为NV12的UV是交错的):

cpp 复制代码
ptrdiff_t ptrDiff = (uint8_t*)ycbcr.cr - (uint8_t*)ycbcr.cb;

if (ycbcr.chroma_step == 2 && ptrDiff == 1) {
    // NV12格式:UV交错,cb在前
    for (uint32_t row = 0; row < height / 2; row++) {
        memcpy((uint8_t*)ycbcr.cb + row * ycbcr.cstride,
               srcUV + row * srcWidth,
               width);  // 一次复制整行,包含U和V
    }
}

黑屏问题解决。

第二个大坑:预览画面定格

问题表现

预览能显示了,但画面经常定格不动,过几秒又突然刷新一下。

排查过程

加了日志统计FPS:

cpp 复制代码
static int frame_count = 0;
static auto last_time = std::chrono::steady_clock::now();

frame_count++;
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
    now - last_time).count();

if (elapsed >= 1) {
    ALOGE("FPS: %d", frame_count);
    frame_count = 0;
    last_time = now;
}

发现问题:

  • 每秒收到350+个processCaptureRequest请求
  • 但只有5-10次真正的帧数据更新

查看详细日志:

复制代码
Buffer #1 is null (cached), streamId=1, bufferId=1
Buffer #1 is null (cached), streamId=1, bufferId=2
Buffer #1 is null (cached), streamId=1, bufferId=3
✓ Processing buffer #1: streamId=1, bufferId=4, hasNewFrame=0  // 偶尔一次
Buffer #1 is null (cached), streamId=1, bufferId=1

原来Camera HAL有个Buffer Cache机制:

  • 第一次请求时,framework传递真实的buffer
  • 后续请求传递null,表示使用缓存的buffer
  • 但我只在buffer不为null时才填充数据,导致缓存的buffer一直是旧数据!

解决方案

实现完整的Buffer Cache管理:

cpp 复制代码
// 1. 定义缓存结构
struct CachedBuffer {
    buffer_handle_t handle;
    uint32_t width;
    uint32_t height;
    int32_t format;
};

std::map<uint64_t, CachedBuffer> mBufferCache;

// 2. 处理新buffer
if (outputBuffer.buffer != nullptr) {
    // import并缓存
    mapper.importBuffer(bufferHandle, width, height, 1, format, 
                       usage, 0, &importedHandle);
    
    CachedBuffer cached;
    cached.handle = importedHandle;
    cached.width = width;
    cached.height = height;
    cached.format = format;
    mBufferCache[outputBuffer.bufferId] = cached;
}

// 3. 使用缓存的buffer
else {
    auto it = mBufferCache.find(outputBuffer.bufferId);
    if (it != mBufferCache.end()) {
        importedHandle = it->second.handle;
    }
}

// 4. 无论新buffer还是缓存buffer,都要填充最新数据
// (在这之前每个请求都读取了新帧)
lockYCbCr(...);
fillYUVData(...);
unlock();

// 5. 处理buffer移除
for (const auto& cache : cachesToRemove) {
    auto it = mBufferCache.find(cache.bufferId);
    if (it != mBufferCache.end()) {
        mapper.freeBuffer(it->second.handle);
        mBufferCache.erase(it);
    }
}

这样每个请求都会更新buffer数据,画面就流畅了。

第三个大坑:拍照绿屏

问题表现

预览正常了,但点击拍照,保存的照片是绿色的。

排查过程

首先测试填充纯色:

cpp 复制代码
// 填充灰色
memset((uint8_t*)ycbcr.y, 128, width * height);
memset((uint8_t*)ycbcr.cb, 128, width * height / 2);

拍出来是正常的灰色照片,说明YUV格式没问题。

那问题出在哪?加日志发现:

复制代码
Copying YUV: src=1280x720, dst=1920x1080

拍照的分辨率比预览高!而我直接用了memcpy

cpp 复制代码
// 错误做法
for (uint32_t row = 0; row < height; row++) {
    memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
           srcY + row * srcWidth,  // row >= 720时越界!
           width);
}

当row=720时,srcY + row * srcWidth已经超出了源数据范围,读到了垃圾数据。

解决方案

实现完整的缩放逻辑,处理三种情况:

情况1:相同分辨率(预览)

cpp 复制代码
if (width == srcWidth && height == srcHeight) {
    // 直接复制
    for (uint32_t row = 0; row < height; row++) {
        memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
               srcY + row * srcWidth,
               width);
    }
}

情况2:目标更小(缩略图)

cpp 复制代码
else if (width <= srcWidth && height <= srcHeight) {
    // 裁剪中心区域
    uint32_t cropX = (srcWidth - width) / 2;
    uint32_t cropY = (srcHeight - height) / 2;
    
    for (uint32_t row = 0; row < height; row++) {
        memcpy((uint8_t*)ycbcr.y + row * ycbcr.ystride,
               srcY + (cropY + row) * srcWidth + cropX,
               width);
    }
}

情况3:目标更大(拍照)

cpp 复制代码
else {
    // 缩放放大
    float scaleX = (float)srcWidth / width;
    float scaleY = (float)srcHeight / height;
    
    for (uint32_t dstRow = 0; dstRow < height; dstRow++) {
        uint32_t srcRow = (uint32_t)(dstRow * scaleY);
        if (srcRow >= srcHeight) srcRow = srcHeight - 1;  // 防止越界
        
        uint8_t* dstRowPtr = (uint8_t*)ycbcr.y + dstRow * ycbcr.ystride;
        const uint8_t* srcRowPtr = srcY + srcRow * srcWidth;
        
        for (uint32_t dstCol = 0; dstCol < width; dstCol++) {
            uint32_t srcCol = (uint32_t)(dstCol * scaleX);
            if (srcCol >= srcWidth) srcCol = srcWidth - 1;
            
            dstRowPtr[dstCol] = srcRowPtr[srcCol];
        }
    }
}

UV平面的处理类似,但要注意:

  • UV平面的尺寸是Y平面的1/4(宽高各减半)
  • NV12格式的UV是交错的,需要按2字节为单位处理
cpp 复制代码
// UV缩放(NV12格式)
for (uint32_t dstRow = 0; dstRow < height / 2; dstRow++) {
    uint32_t srcRow = (uint32_t)(dstRow * scaleY);
    if (srcRow >= srcHeight / 2) srcRow = srcHeight / 2 - 1;
    
    uint8_t* dstUVRow = (uint8_t*)ycbcr.cb + dstRow * ycbcr.cstride;
    const uint8_t* srcUVRow = srcUV + srcRow * srcWidth;
    
    for (uint32_t dstCol = 0; dstCol < width; dstCol += 2) {
        uint32_t srcCol = (uint32_t)((dstCol / 2) * scaleX) * 2;
        if (srcCol >= srcWidth - 1) srcCol = srcWidth - 2;
        
        dstUVRow[dstCol] = srcUVRow[srcCol];          // U
        dstUVRow[dstCol + 1] = srcUVRow[srcCol + 1];  // V
    }
}

至此,拍照功能完全正常。

核心知识点总结

1. NV12格式理解

NV12是YUV420的一种存储格式:

  • Y平面:width × height,每个像素一个字节
  • UV平面:width × (height/2),U和V交错存储(UVUVUV...)

内存布局:

复制代码
[Y Y Y Y Y Y ...]  ← width × height
[U V U V U V ...]  ← width × height/2

2. android_ycbcr结构体

cpp 复制代码
struct android_ycbcr {
    void* y;          // Y平面起始地址
    void* cb;         // U起始地址(指向UV平面的第1个字节)
    void* cr;         // V起始地址(指向UV平面的第2个字节)
    size_t ystride;   // Y平面行跨度(字节)
    size_t cstride;   // UV平面行跨度(字节)
    size_t chroma_step; // UV间隔(2表示交错)
};

关键:

  • cbcr通常指向同一块内存
  • ptrDiff = cr - cb,如果是1,说明是NV12;如果是-1,说明是NV21

3. Camera HAL Buffer Cache机制

Camera Framework为了性能优化,会缓存buffer:

  • 第一次请求:buffer != null,需要import
  • 后续请求:buffer == null,使用缓存

HAL实现必须:

  • 维护bufferId到handle的映射
  • 即使buffer为null,也要填充最新数据
  • 响应cachesToRemove,释放不再使用的buffer

4. 缩放算法

最简单的最近邻插值:

cpp 复制代码
srcX = dstX * (srcWidth / dstWidth)
srcY = dstY * (srcHeight / dstHeight)

需要注意边界检查,防止越界。

性能优化建议

  1. 避免重复缩放

    如果多个stream要相同的缩放尺寸,可以缓存缩放结果

  2. 使用SIMD指令

    YUV复制可以用NEON指令加速(ARM平台)

  3. 异步处理

    把YUV处理放在单独的线程,避免阻塞HAL主流程

  4. 内存池

    缩放时需要临时buffer,可以使用内存池避免频繁分配

调试技巧

  1. 打印十六进制数据

    cpp 复制代码
    ALOGE("Y[0-7]: %02x %02x %02x %02x", 
          yData[0], yData[1], yData[2], yData[3]);
  2. 填充测试图案

    cpp 复制代码
    // 纯色测试
    memset(y, 128, size);  // 灰色
    
    // 彩条测试
    for (int i = 0; i < width; i++) {
        y[i] = (i * 255) / width;  // 渐变
    }
  3. 统计帧率和延迟

    cpp 复制代码
    auto start = std::chrono::steady_clock::now();
    // ... 处理 ...
    auto end = std::chrono::steady_clock::now();
    ALOGE("Process time: %lld ms", 
          std::chrono::duration_cast<std::chrono::milliseconds>(
              end - start).count());
  4. 保存原始YUV文件

    调试时可以把buffer内容dump到文件,用工具查看:

    bash 复制代码
    ffplay -f rawvideo -pixel_format nv12 -video_size 1280x720 frame.yuv

总结

实现Camera HAL虚拟摄像头,主要难点在于:

  1. 理解YUV格式,特别是NV12的内存布局
  2. 正确实现Buffer Cache机制
  3. 处理各种分辨率的缩放

调试过程中要善用日志,打印关键数据,逐步定位问题。遇到画面异常(黑屏、绿屏等),优先用纯色测试,确认格式正确后再处理数据复制。

代码已经在Android 14系统上测试通过,预览帧率稳定在30fps,拍照响应时间在100ms内。

希望这篇文章能帮助到正在做类似工作的朋友,少走一些弯路。

相关推荐
PyHaVolask3 小时前
CSRF跨站请求伪造
android·前端·csrf
走在路上的菜鸟3 小时前
Android学Flutter学习笔记 第五节 Android视角认知Flutter(插件plugins)
android·学习·flutter
2501_915921434 小时前
如何在苹果手机上面进行抓包?iOS代理抓包,数据流抓包
android·ios·智能手机·小程序·uni-app·iphone·webview
_李小白4 小时前
【Android 美颜相机】第五天:GPUImageFilterTools
android·数码相机
冬奇Lab4 小时前
【Kotlin系列05】集合框架:从Java的冗长到函数式编程的优雅
android·kotlin·编程语言
冬奇Lab4 小时前
稳定性性能系列之十四——电量与网络优化:Battery Historian与弱网处理实战
android·性能优化·debug
Coffeeee4 小时前
了解一下Android16更新事项,拿捏下一波适配
android·前端·google
用户41659673693555 小时前
深入解析安卓 ELF 16KB 页对齐:原生编译与脚本修复的权衡
android