← 返回课程

Camera2高级特性

Camera全栈开发(Qcom Camx) 第 8 / 30 节

第 6 章:Camera2 高级特性


本章导读

前面两章讲了 Camera2 API 的基础用法。这章讲那些"不一定每个 App 都用,但做相机开发必须懂"的东西——手动曝光控制、RAW 输出、YUV 内存布局。

尤其是 YUV 格式的内存布局。你迟早会碰到这样一个 bug:图像花屏、绿边、颜色不对——最后发现是 RowStride 算错了。


6.1 手动曝光控制

6.1.1 关闭自动模式

CaptureRequest.Builder builder =
    cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_MANUAL);

// 关掉自动曝光——否则手动值会被覆盖
builder.set(CaptureRequest.CONTROL_AE_MODE,
            CaptureRequest.CONTROL_AE_MODE_OFF);

6.1.2 设置曝光时间和 ISO

// 先查设备支持的曝光范围
Range<Long> exposureRange = chars.get(
    CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE);
// 典型: [100000L, 1000000000L] → 100μs ~ 1s(纳秒单位)

// 设置 33ms 曝光
builder.set(CaptureRequest.SENSOR_EXPOSURE_TIME, 33_000_000L);

// 设置 ISO 200
builder.set(CaptureRequest.SENSOR_SENSITIVITY, 200);

曝光时间和帧率的关系——这是最重要的公式之一

最大帧率 = 1000ms / exposureTime

exposureTime = 33ms → 最大 30fps
exposureTime = 66ms → 最大 15fps
exposureTime = 5ms  → 最大 200fps(HFR 模式)

如果 exposureTime > 1000ms / 目标帧率,硬件会自动降帧

💡 暗光下帧率下降是正常的,不是 bug

相机在暗光下会自动做一件事:加长曝光时间来多收集一些光(第 1 章讲的"曝光三角")。比如从 16ms 拉长到 66ms。每帧曝光 66ms → 每秒最多出 1000/66 ≈ 15 帧。

预览从 30fps 掉到 15fps,用户会感觉"卡"。这不是 HAL 的 bug,是物理规律:曝光时间长了,帧率就降了。

但用户不关心物理规律。所以手机厂商通常会在 AE 算法中加一个上限——比如"曝光时间最长不超过 33ms"——这样帧率最低也能维持 30fps。那曝光不够的亮度怎么补?靠提高 ISO(信号放大)。代价呢?ISO 拉高会放大噪点(这也是第 1 章讲过的)。

这是一个产品级的取舍:要流畅的预览(限制曝光时间,帧率稳)还是要纯净的暗光画质(允许长曝光,噪点少)。不同手机厂商的 AE 策略不同,决定了它们的暗光表现也不同。这和第 21 章要讲的 AE 算法密切相关。

6.1.3 手动对焦

// 查询对焦范围
Range<Float> focusRange = chars.get(
    CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_RANGE);

// 关自动对焦
builder.set(CaptureRequest.CONTROL_AF_MODE,
            CaptureRequest.CONTROL_AF_MODE_OFF);

// 设对焦距离(屈光度,2.0 = 0.5 米)
builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, 2.0f);

6.2 RAW 输出

6.2.1 检查是否支持

StreamConfigurationMap map = chars.get(
    CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
int[] formats = map.getOutputFormats();

boolean hasRaw = false;
for (int f : formats) {
    if (f == ImageFormat.RAW_SENSOR) { hasRaw = true; break; }
}

6.2.2 输出 RAW

ImageReader rawReader = ImageReader.newInstance(
    rawSize.getWidth(), rawSize.getHeight(),
    ImageFormat.RAW_SENSOR, 3);

// 创建 Session 时多配置一条 RAW Stream
List<Surface> surfaces = Arrays.asList(
    previewSurface,
    jpegReader.getSurface(),
    rawReader.getSurface()    // ← 第三路:RAW
);

6.2.3 RAW 格式详解——RAW8/10/12/14/16

中有 YUV 的对比可参考

Sensor 输出的 RAW 数据有不同位深。位深越大,记录的光强信息越精细,但数据量也越大。

格式 位深 每像素存储 一帧 12MP 大小 常见用途
RAW8 8bit 1 字节 ~12 MB 低端 sensor
RAW10 10bit 1.25 字节(打包) ~15 MB 手机主流
RAW12 12bit 1.5 字节(打包) ~18 MB 高端 sensor
RAW14 14bit 1.75 字节(打包) ~21 MB 极高端 sensor
RAW16 16bit 2 字节 ~24 MB 对齐方便,但浪费

CamX 支持 RAW10 和 RAW12(定义在 camx/src/mapperutils/extformatutil/camxformatutilexternal.h)。

RAW10 的打包方式——为什么 4 像素占 5 字节?

RAW8 和 RAW16 很简单:一个像素就是一个字节(RAW8)或两个字节(RAW16),直接对齐。

RAW10 的麻烦在于:10bit 不是 8bit(1 字节)的整数倍。所以不能一个像素一个字节地存。MIPI 标准定义了这种打包方式:

4 个 RAW10 像素 → 5 个字节

Byte 0:  P0[9:2]    ← 像素 0 的高 8 位
Byte 1:  P1[9:2]    ← 像素 1 的高 8 位
Byte 2:  P2[9:2]    ← 像素 2 的高 8 位
Byte 3:  P3[9:2]    ← 像素 3 的高 8 位
Byte 4:  P3[1:0] << 6 | P2[1:0] << 4 | P1[1:0] << 2 | P0[1:0]
         ↑           ↑           ↑           ↑
         像素 3       像素 2       像素 1       像素 0
         低 2 位      低 2 位      低 2 位      低 2 位

直观理解

每个像素 10bit,前 8bit(高 8 位)各占一个字节,后 2bit(低 2 位)四个像素的全部塞进第 5 个字节。

P0 的 10bit = [bit9 bit8 bit7 bit6 bit5 bit4 bit3 bit2] [bit1 bit0]
                └────── Byte 0 的高 8 位 ──────┘   └──→ 塞进 Byte 4

一行 640 像素的RAW10:
  有效字节数 = 640 × 10 / 8 = 800 字节
  (如果是 RAW16 则需要 1280 字节,多了 60%)

一帧 12MP RAW10:
  大小 = 12,000,000 × 10 / 8 = 15,000,000 字节 ≈ 15 MB

💡 为什么要用 RAW10 而不是 RAW8 或 RAW16?

RAW8(8bit = 256 级亮度)的动态范围不够,拍摄高反差场景时高光容易溢出或暗部一片黑。
RAW16(16bit = 65536 级)信息量足够了但是数据量翻倍,占带宽。
RAW10(10bit = 1024 级)是手机上的折中选择:动态范围够用(约 60dB),数据量也不太夸张。

日常开发中你接触到的 sensor 绝大部分都是 RAW10 输出。RAW12 和 RAW14 多用于高端旗舰或专业相机。

RAW12 的打包方式——另一种规律

RAW12 的打包周期是 2 个像素 → 3 个字节:

2 个 RAW12 像素 → 3 个字节

Byte 0: P0[11:4]          ← 像素 0 高 8 位
Byte 1: P1[11:4]          ← 像素 1 高 8 位
Byte 2: P1[3:0] << 4 | P0[3:0]  ← 两个像素各低 4 位

6.3 YUV_420_888 格式详解 ⭐

这是相机开发中最容易踩坑的地方。不理解这节,你迟早会遇到花屏或绿边的 bug。

6.3.1 YUV420 的基本概念

人眼对亮度(Y)敏感,对色度(UV)不那么敏感。
所以 UV 可以降采样——每 4 个像素共享一组 UV。

像素阵列:
  Y0   Y1   Y2   Y3       ← 每个像素都有 Y
  Y4   Y5   Y6   Y7
  Y8   Y9   Y10  Y11
  Y12  Y13  Y14  Y15

  UV 对应关系:
  ┌──────┬──────┐
  │Y0~Y3 │Y4~Y7│ → U0 V0  (每 2×2 一组 UV)
  │Y8~Y11│Y12~Y15│ → U1 V1
  └──────┴──────┘

"420" 的意思是 UV 在水平和垂直方向都降采样 2:1。

6.3.2 NV12 vs NV21

唯一的区别是 UV 排列顺序

NV12(Camera2 默认,高通 ISP 默认输出):
  Y 平面: Y Y Y Y Y Y Y Y ... (width × height 字节)
  UV 平面: U V U V U V U V ... (width × height/2 字节)

NV21(Camera1 默认):
  Y 平面: Y Y Y Y Y Y Y Y ... (width × height 字节)
  UV 平面: V U V U V U V U ... (width × height/2 字节)

高通 ISP 硬件默认输出 NV12。所以从 ISP 出来的 YUV 不需要额外转换就能用。

6.3.3 Stride / RowStride / PixelStride

这是整个课程中被误解最多的三个词。

图像宽 640 像素。但一行 Y 的实际字节数可能不是 640。

为什么?
因为硬件/DMA 有对齐要求(16 或 32 字节对齐)。

Y 平面一行实际存储:
┌──────────────────────────┬────────┐
│  Y[0] Y[1] ... Y[639]    │  PADDING │
│      有效数据 640         │  16    │
└──────────────────────────┴────────┘
←──────── RowStride = 656 ──────────→

RowStride(行跨度):每行 Y 的实际字节数(≥ width)

如果你直接按 width 遍历,会读到 padding 数据。如果你按 width 拷贝,图像会被压缩。

PixelStride(像素跨度):UV 平面中 U 和 V 的交错间隔。

NV12 的 UV 平面:
  U  V  U  V  U  V  U  V  ...
  ↑  ↑
  间隔 = PixelStride = 2

如果 PixelStride = 2:
  每 (width/2) 组 UV 在 RowStride 字节中

核心公式

Y 平面大小     = RowStride × height
UV 平面大小    = RowStride × height / 2
总大小         = RowStride × height × 3 / 2

RowStride ≥ width
RowStride == width 时总大小 = width × height × 3 / 2

6.3.4 正确处理 YUV 数据

Image image = reader.acquireLatestImage();
Image.Plane[] planes = image.getPlanes();

// Y plane
ByteBuffer yBuf = planes[0].getBuffer();
int yRowStride = planes[0].getRowStride();    // ← 可能 > width
int yRowStridePixelStride = planes[0].getPixelStride();

// U plane (NV12)
ByteBuffer uBuf = planes[1].getBuffer();
int uRowStride = planes[1].getRowStride();
int uPixelStride = planes[1].getPixelStride(); // ← NV12 中为 2

// ✅ 正确的逐行拷贝
byte[] nv12 = new byte[image.getWidth() * image.getHeight() * 3 / 2];
int offset = 0;
for (int row = 0; row < image.getHeight(); row++) {
    yBuf.position(row * yRowStride);
    yBuf.get(nv12, offset, image.getWidth());
    offset += image.getWidth();
}

// ❌ 错误的整体拷贝
// byte[] wrong = new byte[yBuf.remaining()];
// yBuf.get(wrong);  // ← 把 padding 也拷进去了!

6.4 本章总结

手动控制: 先关 AE/AF 自动模式 → 再设手动值
RAW 输出: 10bit packed → width×height×10/8
YUV 核心: RowStride — 每行实际字节数
          PixelStride — UV 交错间隔
          逐行拷贝 — 不能整 buffer 拷

学完这章后,当你看到花屏 bug 时,第一反应应该是"RowStride 算对了没?"


篇末实战:写一个 Camera2 调试工具

目标:写一个简单的 App,不拍照不预览,只做一件事——列出设备所有 Camera 的能力信息

功能要求:

  1. 列出所有 Camera ID,标注前后摄
  2. 对每个 Camera 打印:JPEG 最大分辨率、YUV 最大分辨率、FPS 范围、是否支持 RAW、facing/orientation、曝光时间范围、光圈值
CameraManager manager = (CameraManager) getSystemService(CAMERA_SERVICE);
for (String id : manager.getCameraIdList()) {
    CameraCharacteristics chars = manager.getCameraCharacteristics(id);
    int facing = chars.get(CameraCharacteristics.LENS_FACING);
    StreamConfigurationMap map = chars.get(
        CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
    Size[] jpegSizes = map.getOutputSizes(ImageFormat.JPEG);
    // ... 补充其他查询
}

扩展挑战:打开相机,用 ImageReader 获取一帧 YUV,分析 Plane 的 RowStride/PixelStride。