简介:围绕ISP 3A算法展开的技术文档,面向图像处理、嵌入式视觉与摄像机算法开发者,尤其适合需要理解自动白平衡、自动曝光和自动增益控制原理的中级读者。内容从摄像机与人眼视觉的差异切入,系统梳理3A背景、系统运行概念、软件架构、业务主流程,以及AWB、AE、AGC算法流程和曝光控制模式,并结合TI达芬奇平台讨论软硬件协同实现,对Hisi等其他平台的移植与架构选型具有参考价值。压缩包内含1个doc文件,约80KB,目录按背景、架构、流程与算法模块组织,篇幅紧凑但知识点集中。已有5843人学习,可用于建立3A模块职责与图像分析到参数控制链路的整体认识,也可辅助理解偏色、曝光不足等常见问题的成因与调参思路。
1. 需求切入
监控项目联调的最后一天,画面里那块灰色墙面被调成了淡蓝色,转个角度又变回灰色——镜头没脏,编码器也没有问题,问题出在前端的 2A 没有把色温和曝光算对。ISP 3A 算法要处理的正是这类场景:AWB(自动白平衡)负责把被环境光染色的区域还原成中性灰,AE(自动曝光)负责把整体亮度稳定在一个合适的电平上,AGC(自动 gamma 校正)则尝试把动态范围进一步拉开。这套方案基于带硬件 3A 统计引擎的嵌入式 DSP 平台设计,算法模型本身与平台无关,换到别的 SoC 上同样能落地,适合做图像方向毕设、课设,以及需要交付一套可运行 2A 模块的从业者参考。
2. 系统运行概念:H3A 统计引擎与软硬结合的选型取舍
2.1 四种实现方式的成本与灵活性对照
在任何一份 3A 方案里,第一个要回答的问题都是「算法跑在哪」。把这个问题的答案掰开看,是两条独立的线:统计数据的分析在哪做,控制参数的下发在哪做。把这两条线组合起来,就得到工程上常见的四种形态。
| 实现方式 | 统计分析 | 控制下发 | 参数灵活度 | 成本 | 典型适用 |
|---|---|---|---|---|---|
| 专用芯片 | 芯片内部 | 芯片内部 | 低,只能改少数几组寄存器 | 高 | 出货量大、方案固定的消费类产品 |
| 纯软件 | DSP 软件遍历 RAW 数据 | DSP 软件 | 高,算法可随时替换 | 中,占 CPU | 算法预研、参数调试阶段 |
| 软硬结合 | 硬件统计引擎出中间数据 | 软件做决策并回写 | 中高 | 低,复用 SoC 自带模块 | 中高端 IPC、车载、机器视觉 |
| FPGA | FPGA 逻辑 | FPGA 逻辑 | 高但开发周期长 | 很高 | 特殊帧率、特殊分辨率、低延迟场景 |
专用芯片方式最省心,代价是参数调整带来的灵活性受限,画面风格被固化在方案商提供的固件里;纯软件方式最灵活,但每帧都要遍历几十万像素做累加,DSP 的算力会被吃掉一大块,帧率一高就难以兼顾;FPGA 方式在成本不敏感的项目里性能最好,开发周期和人力投入却往往超出毕设或课程设计的承受范围。
我一般会把软硬结合作为默认起点,理由很直接:主流 SoC 的 ISP 流水线里本来就有一个硬件统计模块,它在 RAW 数据进入后端之前,就把每个小块的 RGB 累加值算好放在寄存器里,软件只需要读出来做判断。这套方案选用的就是这条路线——硬件负责「看」,软件负责「想」。硬件统计的延迟低、开销几乎为零,软件决策则保留了随时改算法的空间,调参阶段改一行阈值重新编译就能验证,不用重新流片也不用改逻辑。
需要说明的是,算法层的模型设计并不绑定这种实现方式。候选块筛选、亮度评价、增益计算这些逻辑,换成纯软件遍历 RAW 数据一样成立,只是把数据来源从硬件寄存器换成内存里的帧缓冲。把数据入口抽象成一个设备接口之后,同一套算法就能在两种实现之间迁移,这是后面软件架构那一章要展开的重点。
2.2 窗口划分与累加:统计数据的唯一来源
硬件统计引擎的工作方式比很多人想的简单。它把整幅图像在水平方向和垂直方向各切若干份,切出来的每个小矩形叫一个窗口(paxel),然后以窗口为单位,把窗口内所有像素的 R、G、B 分量分别累加,累加结果右移若干位之后输出。移位这一步很关键:它把累加值压回一个字节左右的量级,本质上是在做「除以窗口内像素数」的近似,也就是给出该窗口的平均颜色。
窗口划分的粒度决定了算法的空间分辨率。切得太粗,比如只有 3×3,一个窗口可能横跨半个天空和半栋楼,统计值失去意义;切得太细,比如 64×64,单个窗口内的像素数太少,噪声占比上升,累加值的方差变大。工程上比较常见的取值是水平 32 份、垂直 32 份左右,配合 720P 以上的分辨率,一个窗口能覆盖几百个像素,噪声被平均掉,同时保留了足够的空间区分度。这套方案里窗口数量和视频窗口保持一致,分辨率一变就必须重建,否则统计区域和实际画面错位,后面所有计算都是错的。
硬件输出的数据结构大致是这样:
/* aew_stat.h —— 硬件统计结果的软件表示,字段与寄存器一一对应 */ typedef struct { unsigned int r_sum; /* 窗口内 R 分量累加值,硬件已右移,量级等同均值 */ unsigned int g_sum; /* 窗口内 G 分量累加值 */ unsigned int b_sum; /* 窗口内 B 分量累加值 */ unsigned short paxel_x; /* 窗口左上角横坐标,用于回溯到画面位置 */ unsigned short paxel_y; } AEW_PAXEL_STAT; typedef struct { unsigned short paxel_w; /* 单窗口像素宽 */ unsigned short paxel_h; /* 单窗口像素高 */ unsigned short count; /* 窗口总数 = 水平份数 × 垂直份数 */ } AEW_PAXEL_INFO;r_sum / g_sum / b_sum这三个值就是后续所有判断的原始输入。AWB 拿它算色温和饱和度,AE 拿它算亮度,两者共用同一份数据,不需要读两遍寄存器。软件侧不要再对这三个值做一次除法,硬件移位后的结果已经足够当作均值使用,重复归一化只会把精度白白丢掉。
硬件统计模块本身还有一些可配置项,比如是否输出窗口内颜色的最大值和最小值、是否只统计中心区域。这套方案只用了最基本的累加结果,原因是最大值和最小值对 AWB 的帮助有限,反而会增加寄存器读取的负担;统计区域也保持全画面,因为 AE 需要看全局亮度分布,裁剪区域会让评价失去代表性。
2.3 从色温评估到增益下发:2A 闭环的基本形态
拿到统计值之后,整个 2A 就是一个负反馈环。AWB 那一侧,评估出当前场景的色温偏向,如果整体偏红,就降低红色通道增益、相应提高蓝色和绿色通道增益;偏蓝则反过来。执行结果是让画面中被判定为「本该是白色」的区域,最终在输出里色度归零。AE 那一侧,评估出当前的整体亮度电平,电平偏低就加大曝光时间或提高增益,偏高就减小。两个环各自独立收敛,但共用同一份统计输入。
原理一句话能说完,真正吃功夫的是判断条件。「偏红」这个结论是怎么来的?画面里一块红布和一块被夕阳染红的白墙,统计值可能完全一样,区分不了就只能靠先验假设:接近中性的区域,饱和度一定低。这条假设构成了 AWB 的全部基础,也构成了它最大的脆弱点——大面积单色场景下,整幅画面几乎没有低饱和区域,算法要么找不到候选块,要么把有色物体误判成白色,两种结果都不好看。
AE 的矛盾在另一边。稳定性和响应速度天然冲突:响应快,场景一变立刻大幅调整,反馈环的延迟一叠加就产生振荡,画面在明暗之间来回晃;响应慢,进出隧道或者开关灯的瞬间会丢失好几帧信息。这套方案用中值亮度做评价、用容差带抑制抖动、用限速步长控制单次调整幅度,三个手段叠在一起才把两个目标同时按住。
理解了这个闭环的基本形态,再看后面的软件分层和算法细节就不会迷路:硬件负责产出一份可信的、空间分块的统计数据,软件负责在这份数据上做统计推断,并把推断结果翻译成一组寄存器写入。整条链路里任何一个环节出错——窗口没对齐、累加值溢出、增益写错通道、曝光档位没有按制式同步——最终都会表现为画面上的偏色或者闪烁,而现象往往和根因隔了好几层,这也是为什么分层和接口约定必须提前定死。
3. 软件架构设计:四层分层、两套接口与一条线程主循环
3.1 四层划分与各层职责边界
2A 模块不是一个孤立的算法函数,它要在视频前端程序里长期驻留,还要能被不同的传感器、不同的 DSP 平台复用。这两条要求决定了它必须有清晰的分层。整套软件切成四层,每层的产出物和依赖关系如下。
| 层次 | 职责 | 主要文件 | 依赖方向 |
|---|---|---|---|
| 应用层 | 调用 2A 能力,处理业务逻辑与用户交互 | main.c、aewtest.c | 只依赖业务层头文件 |
| 业务层 | 按算法层的结论控制设备层参数,管理线程生命周期 | aew_core.c、aew_api.h、libaew.a | 依赖算法层与设备层 |
| 算法层 | AWB / AE / AGC 的纯计算逻辑 | aew_algo.h、ae_core.c、awb_core.c、agc_core.c | 不依赖任何设备 |
| 设备层 | 操作统计引擎、视频前端、传感器 | h3a_dev.c、preview_dev.c、sensor_dev.c | 直接操作寄存器 |
分层的关键在于依赖方向单一。算法层只接收统计数据和配置参数,输出增益和曝光等级,它不知道数据是从寄存器来的还是从文件读的,也不知道结果会被写给谁。这一层可以单独编译成测试程序,喂一组录好的统计值进去,验证算法收敛性,完全不接硬件。设备层则相反,它只认寄存器,不知道这些数字代表什么物理意义。
业务层夹在中间,是整个模块的调度中枢。它持有算法层需要的配置(参考亮度、饱和度上限、曝光档位表),也持有设备层的三个句柄,每一帧从统计引擎读数据、交给算法层算、把结果写回前端和传感器。这种「厚业务层 + 薄算法层 + 薄设备层」的切法,好处是换平台时只需要重写设备层,换算法时只需要替换算法层文件,业务层的线程逻辑几乎不用动。
3.2 业务层接口:把 2A 能力收敛成一组函数
对应用层来说,整个 2A 模块只暴露一个头文件。这个头文件的设计目标很明确:调用方不需要知道内部有几个线程、几块缓冲区,只需要记住几个整数和几个枚举。
/* aew_api.h —— 业务层对应用层暴露的唯一头文件 */ int aew_start(AEW_WINDOW *window, int prev_share_fd); /* 建线程;分辨率变化须先 stop 再 start */ int aew_stop(void); /* 停线程,等循环体退出后再释放资源 */ int aew_set_color(COLOR_PARAM *param); /* 饱和度、色调、锐度等前端颜色参数 */ int aew_set_wb_mode(WB_MODE mode); /* 白平衡模式:自动 / 手动 / 场景预设 */ int aew_set_dnc_mode(DNC_MODE mode); /* 日夜模式:彩色 / 黑白 / 自动 */ int aew_set_vstd(VSTD vstd); /* 制式:PAL / NTSC / SECAM,决定曝光档位表 */ /* 返回该制式下的曝光档位数量,档位时间(毫秒)写入 speeds 数组,长度取 16 足够 */ int aew_get_exposure_speeds(VSTD vstd, float *speeds); /* level 编码:0 自动;1 ~ n-1 手动档位;n 带上下限的自动;n+1 自定义时间手动 * value1 / value2 单位为毫秒,范围 0.1 ~ 80,且 value2 不得小于 value1 */ int aew_set_exposure_level(int level, float value1, float value2); /* gain 取值 0 ~ 100,auto_gain 为 0 时不开启自动增益,为 1 时 gain 表示自动增益上限 */ int aew_set_gain(int gain, int auto_gain);aew_set_exposure_level的编码方式值得多说两句。把「自动」「手动档位」「带上下限的自动」「自定义时间」四种语义压进一个整数加两个浮点,看起来不够优雅,但对上层非常友好:应用层做 UI 时只需要一个下拉框加两个输入框,不用为每种模式准备不同的函数指针。n这个边界值由aew_get_exposure_speeds的返回值动态给出,意味着换制式或者换传感器之后,档位数量变了,上层代码不需要改。这种「约定式编码」在嵌入式接口里很常见,代价是必须把头文件注释写清楚,否则接手的人一定会把n+1和n搞混。
prev_share_fd这个参数处理的是资源归属问题。预览设备通常已经被主程序打开,2A 线程如果自己再打开一次,会出现两个句柄操作同一路视频的情况,行为不可预期。传-1表示由 2A 模块自己创建句柄,传有效值表示复用主程序的句柄。这种「可选共享」的设计在多人协作的项目里能省掉很多扯皮。
3.3 设备层抽象:三个函数指针结构体挡住平台差异
真正让这套代码能跨平台的,是设备层的三个结构体。它们把「统计引擎」「视频前端」「传感器」三类硬件的操作各自收敛成一组函数指针,业务层只持有指针,不持有具体实现。
/* aew_dev.h —— 三类设备的抽象,换硬件只需重写实现并换工厂函数 */ typedef struct { int (*open)(AEW_WINDOW *window); /* 按窗口参数初始化统计引擎 */ int (*close)(void); int (*read)(AEW_PAXEL_STAT *stat, AEW_PAXEL_INFO *info, int paxel_count); } H3A_DEVICE; typedef struct { int (*open)(int share_fd); int (*close)(void); int (*set_wb)(WB_GAIN_PARAM *param); /* 写 R/B 增益寄存器 */ int (*set_gamma)(GAMMA_TABLE *table); /* 写 gamma 查找表 */ int (*set_color)(COLOR_PARAM *param, DNC_MODE mode); /* 饱和度、彩黑转换 */ } PREVIEW_DEVICE; typedef struct { int (*open)(void); int (*close)(void); int (*set_exposure)(EXPLORE_PARAM *param); /* 曝光时间 + 增益,一次写全 */ int (*get_exposure_speeds)(VSTD vstd, float *speeds); int (*set_vstd)(VSTD vstd); int (*set_exposure_level)(int level, float v1, float v2); int (*set_gain)(int gain, int auto_gain); } SENSOR_DEVICE; /* 工厂函数:平台相关,编译时按宏选择 */ void create_h3a_device(H3A_DEVICE *dev); void create_preview_device(PREVIEW_DEVICE *dev); void create_sensor_device(SENSOR_DEVICE *dev);换传感器的动作被压缩成三步:新写一个sensor_xxx.c实现SENSOR_DEVICE的七个函数,在工厂函数里换掉实现,业务层创建时换一个创建函数。业务层、算法层、应用层一行都不用改。换 DSP 平台同理,只需要重写H3A_DEVICE和PREVIEW_DEVICE。
C 语言里做这种抽象,最需要注意的是函数指针的初始化。结构体定义和工厂函数分离,很容易出现某个指针忘了赋值,运行时直接跳到地址 0。我的习惯是在open里对每个非空指针做一次判空,缺一个就直接返回错误码,让问题在启动阶段暴露,而不是等某一帧统计读回来才崩。
3.4 线程主循环:一帧一次的状态流转
业务层用一个常驻线程承载整个控制回路,线程体是一个while循环,外部调用aew_start之后开始转,调用aew_stop之后退出。循环体每一轮的顺序是固定的,顺序错了就会出现「用上一帧的统计控制这一帧的曝光」这类时序问题。
/* aew_core.c —— 线程主循环的骨架,省略错误处理 */ static void *aew_thread(void *arg) { AEW_PAXEL_STAT stat[MAX_PAXEL]; AEW_PAXEL_INFO info; while (g_running) { /* 1. 读硬件统计,失败则跳过本轮,沿用上一帧参数 */ if (h3a->read(stat, &info, MAX_PAXEL) != 0) { usleep(FRAME_INTERVAL_US); continue; } /* 2. 自动白平衡开启时执行 AWB,算出 R/B 增益并下发前端 */ if (wb_mode == WB_AUTO) { if (awb_calc_gain(stat, info.count, &wb_gain) == 0) { preview->set_wb(&wb_gain); } } /* 3. 自动曝光开启时执行 AE,算出曝光等级并下发传感器 */ if (exp_level == 0 || exp_level >= info.count) { ae_next_level(&level, stat, info.count); sensor->set_exposure_level((int)level, 0, 0); } /* 4. 自动日夜模式:按亮度决定是否切黑白,切换时同步改颜色参数 */ if (dnc_mode == DNC_AUTO) { dnc_update(stat, info.count, preview); } usleep(FRAME_INTERVAL_US); } return NULL; }四个步骤的顺序有讲究。统计读取必须放在最前,因为它是最新一帧的信息来源;AWB 下发放在 AE 之前,是因为白平衡增益只影响颜色通道,不影响亮度评价,先写不会干扰后面的曝光计算;日夜模式放在最后,它一旦切到黑白,会把饱和度设成 0,如果放在前面会污染 AWB 的候选块判断。
usleep(FRAME_INTERVAL_US)给的是帧间隔,让控制频率和视频帧率对齐。有人图省事直接写死一个 10ms,结果在 25 帧的系统里每帧控制 4 次,反馈环的增益被放大四倍,画面开始周期性抖动,排查半天才发现是节拍问题。控制周期一定要跟着帧率走,不能拍脑袋定。
read失败时不退出循环,而是沿用上一帧参数继续跑,这是工程上的保守选择。统计读取失败往往意味着驱动层某次 DMA 没完成,偶发一次不影响画面,直接退出线程反而会让整个预览黑屏。但失败次数要计数,连续失败超过阈值再报错退出,避免无限空转。
4. AWB 与 AE 的算法落地:HSI 饱和度筛选与中值亮度反馈环
4.1 AWB 的候选块筛选:为什么用 HSI 饱和度而不是 RGB 色差
AWB 的全部难点集中在一句话上:怎么找出一块「原本是白色,只是被环境光染色了」的区域。RGB 三个通道的数值本身不带语义,一块浅灰和一块浅米色在数值上可能只差几个刻度,中间隔着的就是「染色的白」和「物体本色」这条模糊边界。
常见做法里有一类是在 YUV 空间里看红蓝色差,判据形如「红色差和蓝色差都比较小」。这类方法在色卡上表现不错,实际场景里问题出在两个分量之间的关系上:当环境光偏暖时,红色差和蓝色差的数值会同时往一个方向偏,判据的两个阈值互相耦合,调大一点就漏检,调小一点就误检,参数很难一次性调好。
这套方案换了个思路:先把 RGB 转成 HSI 空间,只用饱和度 S 做筛选。转换里真正需要的只有两个量:
/* HSI 转换里 AWB 实际用到的两个分量,H 完全不需要,省掉一次反三角运算 */ I = (r + g + b) / 3.0; /* 亮度 */ S = 1.0 - 3.0 * min3(r, g, b) / (r + g + b); /* 饱和度,取值 0 ~ 1 */这样做的好处是把二维判断降成一维。饱和度这个量有明确的物理含义:它衡量颜色的纯度,越接近灰色越低。一块白墙在钨丝灯下整体偏黄,但它的纯度依然很低,S 值不会大;一块橙色的安全帽,无论灯光怎么变,S 值都高。于是判据变成「S 小于某个上限,且亮度落在经验区间内」,两个条件互不耦合,参数可以独立调。
亮度上下限同样重要,而且经常被忽略。亮度太低的块,RGB 累加值的基数小,噪声占比高,算出来的饱和度误差很大,可能把一个真正的中性点在噪声作用下推成高饱和;亮度太高的块,某个通道已经接近饱和,累加值被截断,饱和度计算直接失真。这两类块都必须剔除,否则 AWB 会在夜景和高光区域表现得很不稳定。
4.2 双阈值筛选与增益计算
候选块选出来之后,剩下的就是平均和相除。平均颜色代表这批「本该是中性」的块当前偏成了什么色,把三个分量的均值除一除,就得到把这批块拉回灰色的增益。
/* awb_core.c —— 候选块筛选与增益计算,纯计算,不碰任何寄存器 */ #define LUM_MIN 32 /* 太暗的块基数小,饱和度误差大 */ #define LUM_MAX 224 /* 太亮的块已经溢出,饱和度不可信 */ #define SAT_MAX 0.22f /* 饱和度上限,超过认为是物体本色 */ #define MIN_RATIO 0.15f /* 候选块占比低于此值视为不可信 */ int awb_calc_gain(const AEW_PAXEL_STAT *stat, int count, WB_GAIN_PARAM *out) { int i, n = 0; double sr = 0, sg = 0, sb = 0; for (i = 0; i < count; i++) { double r = stat[i].r_sum, g = stat[i].g_sum, b = stat[i].b_sum; double sum = r + g + b, lum, sat; if (sum <= 0) continue; /* 全黑窗口直接跳过 */ lum = sum / 3.0; if (lum < LUM_MIN || lum > LUM_MAX) continue; sat = 1.0 - 3.0 * min3(r, g, b) / sum; /* HSI 的 S 分量 */ if (sat > SAT_MAX) continue; /* 有色物体,不是候选块 */ sr += r; sg += g; sb += b; n++; } /* 候选块太少说明场景里没有可信的中性参考,宁可保持上一帧增益 */ if (n < (int)(count * MIN_RATIO)) return -1; sr /= n; sg /= n; sb /= n; out->r_gain = sg / sr; /* 把 R 拉到 G,等效于把色度归零 */ out->b_gain = sg / sb; out->g_gain = 1.0; /* G 为基准通道,不参与调整 */ return 0; }这段代码里有三个细节值得展开。MIN_RATIO这个保护阈值是血泪经验换来的:在大面积纯色场景下,比如整幅画面都是草地,候选块数量可能只有几个,算出来的均值全是噪声,增益会在 0.6 到 1.6 之间乱跳,画面呈现出周期性的偏色。加上占比保护之后,这种情况直接沿用上一帧结果,画面稳定得多。
第二个细节是增益的限幅。真实代码里r_gain和b_gain还要再套一层上下限,通常限制在 0.5 到 2.0 之间。传感器和视频前端的增益寄存器都有物理范围,软件算出一个 4.0 的增益写进去,硬件要么截断要么溢出,前者导致校正不到位,后者直接导致画面崩塌。
第三个细节是收敛速度。直接把算出来的增益写进寄存器,遇到光源突变(比如从窗口走到走廊)时会有一个明显的跳变。通常的做法是加一阶滤波:新增益按固定比例混入旧增益,比例取 0.2 到 0.3 之间,几帧之内平滑过渡到目标值。滤波系数太小会导致切换过慢,太大则起不到平滑作用,这个值需要在实机上用开关灯的录像反复看才能定下来。
4.3 AE 的亮度评价:中值代替均值,容差带抑制抖动
AE 的输入是同一份统计值,输出是一个曝光等级(浮点数,整数部分对应曝光档位,小数部分用于插值)。评价亮度的方式直接决定了整个环路的性格。
最直觉的做法是把所有窗口的亮度取平均。问题和 AWB 那边一样:画面里有一块很亮的窗户或者一盏灯,它的亮度值远高于其他区域,平均值会被它拉高,算法以为整体已经很亮了,于是压低曝光,结果室内真正需要看清的部分变得一片漆黑。反过来,如果画面上方有一个大面积的暗区(比如夜间天空),平均值被拉低,算法拼命提亮,高光区域直接溢出。
这套方案用的是中值。把所有窗口的亮度排序,取中间那个值作为整体亮度评价。中值对局部极值不敏感,一块窗户再亮也只是一个样本,不会影响中位数。这样得到的评价更接近「画面主体有多亮」,而不是「画面平均值有多亮」。
/* ae_core.c —— 中值评价 + 容差带 + 限速步长 */ #define Y_REF 128.0f /* 参考亮度,8bit 量纲 */ #define Y_TOL 5.0f /* 容差带:进来就不动,抑制参考点附近的来回抖 */ #define STEP_MAX 0.5f /* 单次最多调整半档,防止明暗突变时过冲 */ float ae_next_level(float level, const float *paxel_y, int count) { float buf[MAX_PAXEL], cur, err, step; int i, j; /* 拷贝后插入排序,窗口数通常在千级以内,开销可控 */ for (i = 0; i < count; i++) buf[i] = paxel_y[i]; for (i = 1; i < count; i++) { float key = buf[i]; for (j = i - 1; j >= 0 && buf[j] > key; j--) buf[j + 1] = buf[j]; buf[j + 1] = key; } cur = buf[count / 2]; /* 中值即评价亮度 */ err = Y_REF - cur; if (err > Y_TOL) { /* 偏暗,提亮 */ step = err / Y_REF * 4.0f; /* 误差越大步长越大,接近目标时自动收敛 */ return level + (step > STEP_MAX ? STEP_MAX : step); } if (err < -Y_TOL) { /* 偏亮,压暗 */ step = -err / Y_REF * 4.0f; return level - (step > STEP_MAX ? STEP_MAX : step); } return level; /* 落在容差带内,保持不动 */ }Y_TOL这个容差带是整个 AE 稳定性的关键。没有它,算法会在参考亮度两侧反复穿越,每一次穿越都下发一次参数,画面呈现出一两个等级的呼吸式抖动。加上容差带之后,只有当误差超出带外才会动作,带内一律不动,抖动立刻消失。容差带的宽度和噪声水平相关,噪声大的传感器需要更宽的带。
STEP_MAX处理的是另一类问题。场景从室内切到室外,误差可能瞬间达到 60 个亮度单位,如果按比例算出的步长直接下发,光圈和增益一步到位,反馈环会冲过目标再回调,来回两三次才稳住,观感上就是画面猛地一亮再慢慢压下来。限制单次最大调整幅度之后,同样的场景需要三四帧才能收敛,但过程是单调的,没有过冲。
4.4 曝光时间与市电频率同步:横纹的来源与阶跃补偿
在室内日光灯下调试,很多人会遇到一种现象:画面出现几条滚动的横条纹,而且条纹的滚动速度跟曝光时间有关。这不是传感器坏了,是曝光时间和光源的闪烁周期不同步造成的。
市电驱动的日光灯,光强以两倍市电频率波动。50Hz 的电网,光强波动周期是 10 毫秒;60Hz 的电网,周期是 8.33 毫秒。如果曝光时间恰好是周期的整数倍,每一次曝光期间收集到的光量相同,画面均匀;如果曝光时间是 1.5 个周期,那么不同行开始曝光的时刻处于光强的不同相位,累积的光量不同,就形成明暗条纹。CMOS 卷帘快门逐行曝光,条纹会随帧序滚动。
处理办法在曝光档位表上做文章:档位不是连续取值,而是按当前制式预先生成,全部落在半周期或整周期的整数倍上。AE 算出来的连续等级先向下取到最近的安全档位,剩下的光量缺口用增益补上。这个「取整 + 增益补偿」的动作就是阶跃补偿,它让曝光时间始终满足同步条件,同时不损失总体的曝光量。
/* sensor_core.c —— 曝光等级到寄存器值的换算,含阶跃补偿 */ float exposure_compensate(float level, VSTD vstd, float *gain_comp) { float half_period = (vstd == VSTD_PAL) ? 10.0f : 8.33f; /* 半周期,毫秒 */ float base = exposure_table[(int)level]; /* 档位表里的安全值 */ float want = level_to_ms(level); /* 连续值换算出的理想时间 */ float ratio = want / base; *gain_comp = ratio; /* 时间没给够的部分用增益补,超过 1 表示需要提增益 */ return base; }gain_comp的取值需要限幅,通常在 0.8 到 1.25 之间,超出范围说明当前档位和目标亮度差距太大,应该直接跳到下一个安全档位而不是靠增益硬撑。增益放得太大,噪点会明显上升,画面虽然亮了但质感全无,这在暗光监控场景里是致命的。
曝光控制模式最终呈现给应用层的是五种组合,每种的特点和适用场合整理如下。
| 曝光模式 | 处理方式 | 特点 |
|---|---|---|
| 自动曝光 | AE 算出的等级换算为曝光时间,从当前制式的档位表取最接近值 | 曝光时间与市电频率匹配,无闪烁;只支持较大曝光时间 |
| 手动档位 | 从档位表指定一个时间,直接换算寄存器值 | 只支持较小曝光时间,适合固定光照环境 |
| 上下限自动 | AE 结果用上下限参数截取后再换算 | 范围灵活;垂直同步信号未对齐时可能闪 |
| 自定义时间 | 直接输入毫秒数换算寄存器值 | 调试和测试最方便,不受档位表约束 |
| 自动增益 | AE 等级换算增益,用上限截取 | 上限默认取 AD 最大增益的一半,用于控制信噪比 |
自动曝光与自动增益同时开启时有一个已知的坑:两个寄存器写入存在时间差,加上曝光时间的档位跳变本身是离散的,中间会出现一帧亮度不连续,观感上就是画面偶尔闪一下。缓解办法是把两次写入合并成一次设备调用,由设备层保证先写曝光再写增益,中间不插入其他操作;同时给增益加一个变化率限制,让它在曝光跳档的同一帧内平滑跟进。
5. 避坑与排查:AWB 褪色、AE 振荡与切换闪屏的五个现场问题
5.1 AWB 相关:大面积色块褪色与夜间偏色
现象:镜头对准一面浅色墙壁或者一大片纯色工装,几秒之内颜色被抽干,画面变成灰白;镜头一转开,颜色又恢复。
原因:候选块筛选只做了单块判断,没有做空间一致性校验。大面积浅色区域的饱和度恰好落在阈值以内,被大量计入候选块,AWB 认为「这就是被染色的白」,直接把增益往灰的方向拉。这类场景里,真正的白色参考反而占比很低,被淹没在错误的多数里。
解决:在单块阈值之外再叠一层限制,一是收紧饱和度上限,让浅色但不中性的块进不来;二是对增益变化做限速,每帧最多调整 5%,即使判断错了也不会在几帧内把颜色抽干;三是统计候选块的空间分布,如果候选块在画面上呈现明显的聚集(集中在一个大矩形里),降低其权重。第三条代价最大,前两条在多数场景下已经够用。
现象:夜间画面整体偏蓝或偏绿,色卡测试正常,实景却不行。
原因:夜间照度低,多数窗口的亮度落在LUM_MIN以下被剔除,剩下的候选块集中在少数几个光源附近,样本严重不足,增益计算出的是一个随机值。
解决:夜间切换到预设增益,不跑自动。通常的做法是设定一个照度门限,低于门限时锁定 AWB 增益为白天收敛的最后一组值,或者直接切到红外模式走黑白。硬要在极低照度下跑自动白平衡,等于让算法在噪声里找信号。
5.2 AE 相关:明暗切换振荡与爬行
现象:开关灯的瞬间,画面亮度在目标值附近来回晃三四次才稳定,严重时一直小幅呼吸。
原因:容差带设得太窄,或者步长系数太大。反馈环的延迟来自帧间隔,一次调整要等下一帧的统计回来才能看到效果,如果单次调整幅度超过了误差本身,就会冲过目标,下一帧再往回调,形成极限环。
解决:先确认容差带宽度是否大于噪声引起的亮度波动,通常取 4 到 6 个亮度单位;再把步长系数降下来,让单次调整量不超过误差的一半,配合STEP_MAX限幅。调试时把每一帧的误差和下发等级打到串口,看曲线是单调收敛还是正弦振荡,比盯着画面猜快得多。
现象:白天到夜间的过渡时段,画面亮度缓慢地一阵一阵变化,像在爬。
原因:曝光时间已经顶到档位表的上限,再往上只能靠增益,而增益有上限,两个限制叠加之后,AE 每次算出更大的等级,实际执行却不变,误差一直存在,形成缓慢的周期性尝试。
解决:在 AE 内部区分「曝光时间已饱和」和「增益已饱和」两种状态,前者允许继续提增益,后者直接钳住等级不再增加,并给上层报一个照度不足的状态位。让算法知道自己已经到顶,比让它一直徒劳地算下去要好。
5.3 集成相关:切换分辨率后画面错位与线程退出卡死
现象:从 1080P 切到 720P,画面开始出现块状偏色,偏色区域的位置和画面内容对不上。
原因:统计窗口的数量和视频分辨率绑死,分辨率变了之后没有重建统计引擎,硬件还在按旧的分块输出数据,软件按新的窗口数量去解读,位置全部错位。
解决:把「分辨率变化」当成一次完整的生命周期事件,先aew_stop销毁线程和统计引擎,重新配置窗口参数,再aew_start。不要在运行中动态改窗口数量,中间态的寄存器组合很容易让统计引擎进入未定义状态。
现象:调用停止接口之后,程序卡住不返回,或者返回了但线程还在跑。
原因:停止标志用的是普通布尔变量,编译优化之后循环体读不到更新;或者线程正阻塞在read调用上,标志位改了也出不来。
解决:标志位加volatile,循环体内每次判断都重新读;read设置超时,超时返回错误码让循环有机会检查标志;stop里用条件变量等待线程真正退出,而不是发个标志就返回。这个问题在没有开启优化时测不出来,一开-O2立刻暴露,属于典型的玄学现象。
6. 验收与答辩:一份能拿得出手的 3A 效果评测清单
3A 这类模块最难的不是写出来,是证明它「好」。答辩或者交付评审时,如果只能说「我看着还行」,基本拿不到分。下面这套评测清单是我反复用过的一套,覆盖了主要场景,也方便现场演示。
| 测试项 | 方法 | 通过标准 |
|---|---|---|
| 白平衡准确性 | 24 色卡,分别在暖光、冷光、自然光三种光源下拍摄,取色卡第二行灰色块 | 灰块 R/G/B 偏差在 ±5% 以内,色卡整体无色偏 |
| 白平衡收敛 | 从暖光切到冷光,逐帧记录灰块色度 | 15 帧内收敛到标准以内,过程单调无过冲 |
| 曝光准确性 | 18 级灰阶卡,逐级测试 | 中间灰阶的输出亮度与参考值误差在 ±5 个亮度单位以内 |
| 曝光动态响应 | 突然开关灯,记录亮度曲线 | 20 帧内稳定,超调不超过目标值的 15%,无持续振荡 |
| 工频闪烁 | 日光灯下对准均匀白墙,播放实时视频 | 无可见滚动横纹,慢放逐帧检查一致 |
| 大面积色块 | 对准单一颜色墙面持续 10 秒 | 颜色不被抽干,增益变化幅度小于 10% |
| 长时间稳定性 | 连续运行 24 小时 | 无内存增长,无参数漂移,无线程退出 |
演示时最容易出彩的是第二项和第四项,因为它们能直观展示算法在动态场景下的表现。白平衡收敛那一段,建议把每一帧的 R/B 增益打成曲线叠加在视频旁边,评审看到的是「算法在 15 帧内平滑地把偏色拉回来」,而不是「画面颜色变了」这种模糊描述。曝光动态响应同理,把亮度曲线和曝光等级曲线画在一张图上,振荡、超调、收敛时间一目了然。
答辩环节有几个问题几乎必问,提前准备好答案能省很多事。第一个是「为什么用中值不用均值」,答案要落到局部极值对均值的影响上,最好能举一个画面里有高亮窗户的例子。第二个是「为什么用 HSI 饱和度而不是 RGB 色差」,答案要讲清两个阈值耦合导致的调参困难,以及饱和度作为单一判据的物理意义。第三个是「换一个传感器要改多少代码」,这时候把设备层的三个结构体拿出来,说清只需要重写SENSOR_DEVICE的实现并换一个工厂函数,业务层和算法层零改动。
还有一项经常被忽略但评审很看重的内容:说明清楚哪些部分没做完,以及为什么。AGC 那一路就是个典型例子,直方图统计、gamma 曲线生成、曲线下发这条链路理论上不复杂,实际接入后图像整体发灰,怎么调曲线都对不上,最后定位到是视频前端的 gamma 表写入时序和驱动版本之间存在不匹配,属于硬件链路的黑匣子。在方案里如实写清「已实现算法,未接入硬件,原因是 X」,比含糊带过或者硬凑一张效果图要可信得多。工程方案的价值不在于每一项都完美,而在于每一处取舍都有据可查。
从那以后我每次交付 2A 相关的东西,都会把开关灯那一段录屏慢放三遍,再随手挑一个纯色墙面怼上去看十秒,这两下能提前暴露八成问题。希望帮到你。
本文还有配套的精品资源,点击获取