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.cb和ycbcr.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表示交错)
};

关键:

  • cb和cr通常指向同一块内存
  • 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内。

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

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui