4、图像预处理基础(下):色彩空间转换、直方图均衡化、Gamma校正

上一节我们聊了聊图像缩放和滤波,那些操作基本不改变像素的“颜色”含义。但这一节不一样——我们要动真格的了。

色彩空间转换、直方图均衡化、Gamma校正,这三板斧在嵌入式视觉部署里几乎天天见。我最早在ARM Cortex-A7上做车牌识别时,就吃过色彩空间没选对的亏。嗯,今天咱们就把这些坑一个个填上。

4.1 色彩空间转换:从RGB说起

摄像头出来的原始数据,绝大多数是RGB格式。但RGB有个毛病——它对亮度变化太敏感,而且三个通道相关性高,不适合做后续分析。

所以我们要转。转成什么?看场景。

4.1.1 RGB转灰度

最常用的转换,没有之一。为什么?因为很多算法根本不需要颜色信息,比如边缘检测、轮廓提取、二值化。你想想看,一个640×480的彩色图,三个通道就是921KB;转成灰度,直接省掉2/3的内存。在嵌入式设备上,这省下来的可能就是帧率从15fps提到30fps的关键。

灰度转换不是简单的(R+G+B)/3。人眼对绿色最敏感,对蓝色最迟钝。所以标准公式是:

Gray = 0.299 × R + 0.587 × G + 0.114 × B

我个人习惯在嵌入式平台上用整数近似,避免浮点运算:

Gray = (77 × R + 150 × G + 29 × B) >> 8

这个移位操作比浮点乘法快一个数量级。我在STM32H7上实测过,处理一帧VGA图像,整数版本比浮点版本快了约3.2ms。

小技巧: 如果你的平台支持NEON或SIMD指令集,可以用向量化指令一次处理8个像素。我在全志V3s上做过优化,单帧灰度转换时间从2.1ms降到了0.4ms。

4.1.2 RGB转YUV

YUV是视频编码和ISP管线里的常客。Y是亮度分量,U和V是色度分量。为什么视频压缩喜欢YUV?因为人眼对亮度细节敏感,对颜色细节不敏感——你可以把UV分量压缩到更低的分辨率,人眼看不出区别。

转换公式(BT.601标准):

Y  =  0.299 × R + 0.587 × G + 0.114 × B
U  = -0.147 × R - 0.289 × G + 0.436 × B + 128
V  =  0.615 × R - 0.515 × G - 0.100 × B + 128

我在项目中遇到过一个问题:直接从摄像头读到的YUV数据,显示出来颜色偏绿。查了半天,发现是U和V的顺序搞反了——有的传感器输出YUYV,有的输出YVYU。这个坑,踩过一次就记住了。

格式 存储顺序 常见场景
YUYV Y0 U0 Y1 V0 Y2 U1 Y3 V1 ... USB摄像头、OV系列传感器
YVYU Y0 V0 Y1 U0 Y2 V1 Y3 U1 ... 部分索尼传感器
NV12 Y平面 + UV交错平面 视频编码、H.264/H.265
NV21 Y平面 + VU交错平面 Android Camera、高通平台
注意: 在嵌入式平台上,YUV转RGB的浮点运算开销很大。我曾经在RK3399上做实时显示,直接用浮点公式只能跑到12fps。后来换成查表法(预计算256×256的查找表),直接飙到28fps。

4.2 直方图均衡化

说白了,就是把图像的灰度分布“拉平”。如果一张图整体偏暗,直方图都挤在低灰度区;偏亮就挤在高灰度区。均衡化就是把这些像素重新分配,让它们均匀分布在0~255之间。

效果很直观:对比度提升,细节变明显。我在做夜间车牌识别时,直方图均衡化几乎是必做的一步。

4.2.1 算法原理

步骤不复杂:

  1. 统计每个灰度级的像素个数(直方图)
  2. 计算累积分布函数(CDF)
  3. 用CDF映射原始灰度到新灰度

映射公式:

new_gray = round( (CDF(gray) - CDF_min) / (N - CDF_min) × 255 )

其中N是总像素数,CDF_min是第一个非零累积值。

4.2.2 嵌入式实现要点

直方图均衡化在PC上跑很简单,但在嵌入式设备上要注意几点:

  • 内存占用: 256个int的数组,才1KB,没问题
  • 计算量: 两遍遍历图像(一遍统计,一遍映射),O(n)复杂度
  • 查表加速: 映射关系可以预计算成256字节的查找表
// 嵌入式C实现片段
uint8_t lut[256];
uint32_t hist[256] = {0};
uint32_t cdf[256] = {0};
uint32_t total = width * height;

// 第一步:统计直方图
for (int i = 0; i < total; i++) {
    hist[src[i]]++;
}

// 第二步:计算CDF
cdf[0] = hist[0];
for (int i = 1; i < 256; i++) {
    cdf[i] = cdf[i-1] + hist[i];
}

// 第三步:生成查找表
uint32_t cdf_min = 0;
for (int i = 0; i < 256; i++) {
    if (cdf[i] != 0) { cdf_min = cdf[i]; break; }
}
for (int i = 0; i < 256; i++) {
    lut[i] = (uint8_t)((cdf[i] - cdf_min) * 255 / (total - cdf_min));
}

// 第四步:查表映射
for (int i = 0; i < total; i++) {
    dst[i] = lut[src[i]];
}
经验之谈: 直方图均衡化对噪声敏感。如果图像本身噪声大,均衡化会把噪声也放大。我建议先做一次轻微的高斯滤波(3×3核),再做均衡化,效果会好很多。

4.3 Gamma校正

Gamma校正解决的是“显示非线性”问题。人眼对暗部的变化比亮部敏感,但显示器的亮度响应是线性的。所以需要对图像做非线性映射,让显示效果更自然。

公式很简单:

out = 255 × (in / 255) ^ (1 / gamma)

gamma值通常取2.2。小于1时图像变亮,大于1时变暗。

4.3.1 什么时候用Gamma校正?

  • 显示优化: 图像在LCD/OLED上显示偏暗时,调低gamma值
  • 预处理: 某些特征提取算法(如SIFT、SURF)对光照敏感,Gamma校正可以归一化光照
  • 低光照增强: 夜间图像用gamma=0.5~0.7,能明显提亮暗部

4.3.2 嵌入式优化

浮点幂运算在MCU上很慢。我一般用查表法:

// 预计算256级gamma查找表
float gamma = 0.5f;  // 提亮
uint8_t gamma_lut[256];
for (int i = 0; i < 256; i++) {
    gamma_lut[i] = (uint8_t)(255.0f * powf(i / 255.0f, 1.0f / gamma));
}

// 运行时直接查表
for (int i = 0; i < total; i++) {
    dst[i] = gamma_lut[src[i]];
}

查表法把每次像素处理从几十个周期降到了几个周期。我在ESP32-S3上做过测试,查表法处理320×240图像只需要0.8ms,而浮点幂运算需要12ms——差了15倍。

避坑指南: 我曾经在批量生产时发现,同一批摄像头模组出来的图像,有的偏亮有的偏暗。后来发现是LCD屏的gamma曲线不一致。解决方案是在产线做一次gamma校准,把每块屏的gamma值写入EEPROM,运行时读取并动态生成查找表。

4.4 本章知识结构

下面这张图把三个核心知识点串起来了。你可以看到它们在嵌入式视觉流水线中的位置和关系。

图像预处理核心流程(第4章) 原始图像(RGB) 色彩空间转换 直方图均衡化 Gamma校正 RGB → 灰度(亮度加权) RGB → YUV(视频编码) YUV ↔ RGB(显示转换) 统计直方图 计算累积分布函数 查表映射灰度值 幂运算映射 预计算查找表 运行时查表加速 预处理后图像 三个操作可以独立使用,也可以组合:先Gamma校正提亮暗部 → 再直方图均衡化增强对比度 → 最后转灰度做后续分析 嵌入式优化核心:查表法 + 整数运算 + SIMD向量化

4.5 小结

这一节的内容,说白了就是三件事:

  • 色彩空间转换: 把RGB变成更适合处理的形式。灰度省内存,YUV方便压缩
  • 直方图均衡化: 拉平灰度分布,提升对比度。注意先降噪再均衡
  • Gamma校正: 非线性映射,补偿人眼感知。查表法是嵌入式标配

这三个操作在嵌入式视觉流水线里几乎天天用。我建议你在自己的开发板上把代码跑一遍,看看不同参数对实际图像的影响。只有亲手调过gamma值、看过均衡化前后的直方图变化,才能真正理解这些算法的脾气。