前面两章讲了 Camera2 API 的基础用法。这章讲那些"不一定每个 App 都用,但做相机开发必须懂"的东西——手动曝光控制、RAW 输出、YUV 内存布局。
尤其是 YUV 格式的内存布局。你迟早会碰到这样一个 bug:图像花屏、绿边、颜色不对——最后发现是 RowStride 算错了。
CaptureRequest.Builder builder =
cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_MANUAL);
// 关掉自动曝光——否则手动值会被覆盖
builder.set(CaptureRequest.CONTROL_AE_MODE,
CaptureRequest.CONTROL_AE_MODE_OFF);
// 先查设备支持的曝光范围
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 算法密切相关。
// 查询对焦范围
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);
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; }
}
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
);

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)。
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 的打包周期是 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 位

人眼对亮度(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。
唯一的区别是 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 不需要额外转换就能用。
这是整个课程中被误解最多的三个词。
图像宽 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
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 也拷进去了!
手动控制: 先关 AE/AF 自动模式 → 再设手动值
RAW 输出: 10bit packed → width×height×10/8
YUV 核心: RowStride — 每行实际字节数
PixelStride — UV 交错间隔
逐行拷贝 — 不能整 buffer 拷
学完这章后,当你看到花屏 bug 时,第一反应应该是"RowStride 算对了没?"
目标:写一个简单的 App,不拍照不预览,只做一件事——列出设备所有 Camera 的能力信息。
功能要求:
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。