
C# 相机图像阴影校正:使用OpenCvSharp实现工业相机阴影平场校正功能
那台产线相机拍出来的图左边亮右边暗,阴影校正搞了两周才搞定
去年接了个光伏电池片EL检测的项目,产线上了四台相机做全覆盖拍摄。结果出来的图让人头疼------同一批电池片,左边相机拍的明显偏亮,右边相机拍的偏暗,拼接后中间一道明显的分界线。客户验收的时候指着屏幕问:"这到底是电池片有问题还是你们相机有问题?"
排查了一圈,光源均匀性没问题,镜头也没脏,后来才意识到是镜头渐晕(Vignetting)加上光源角度不一致导致的阴影。这种阴影不是简单的亮度不均,而是像素级的灰度渐变,调曝光、调增益根本解决不了。
后来在OpenCvSharp里写了个阴影平场校正,用白场图和黑场图做标定,再对每帧采集的图做除法校正,终于把四台相机的亮度一致性拉到了±2%以内。
这篇把过程中几种实用方法整理出来,供同行参考。
一、阴影到底从哪来的?
工业相机拍出来的阴影,通常不是单一原因造成的,而是几个因素叠加:
| 来源 | 表现 | 能不能通过调相机参数解决 |
|---|---|---|
| 镜头渐晕 | 图像四角比中心暗,呈圆形渐变 | 不能,是光学特性 |
| 光源角度/距离不均 | 一侧亮一侧暗,线性或区域渐变 | 调光源位置可能改善,但产线空间有限 |
| 传感器响应不均 | 固定模式的亮度差异,每帧都一样 | 不能,是 sensor 制造工艺 |
| 灰尘/污渍 | 局部暗斑,位置固定 | 清洁镜头可解决,但产线不能天天拆相机 |
**平场校正(Flat-Field Correction)**的核心思路是:拍一张均匀白场的参考图,记录每个像素点的"基准亮度",后续每帧图都除以这个基准,把不均匀性抵消掉。
二、OpenCvSharp实现阴影平场校正
方法一:基础除法校正(最常用)
这是最标准的平场校正流程:先拍白场(White Field)和黑场(Dark Field),然后用公式:
校正后 = (原始图 - 黑场) / (白场 - 黑场) × 目标灰度
csharp
using OpenCvSharp;
public class FlatFieldCorrector
{
private Mat _whiteField; // 白场参考图
private Mat _darkField; // 黑场参考图(关光源或盖镜头盖拍摄)
private Mat _gainMap; // 预计算的增益图
/// <summary>
/// 标定:拍摄白场和黑场,计算增益图
/// </summary>
public void Calibrate(Mat whiteField, Mat darkField, double targetGray = 128.0)
{
_whiteField = whiteField.Clone();
_darkField = darkField.Clone();
// 计算增益图:gain = targetGray / (white - dark)
// 注意避免除零
Mat diff = new Mat();
Cv2.Subtract(_whiteField, _darkField, diff, null, MatType.CV_32F);
// 加一个小数避免除零
Cv2.Add(diff, new Scalar(0.001), diff);
_gainMap = new Mat(diff.Size(), MatType.CV_32F, new Scalar(targetGray));
Cv2.Divide(_gainMap, diff, _gainMap);
diff.Dispose();
// 保存标定结果,方便后续复用
SaveCalibration("calibration_data");
}
/// <summary>
/// 对输入图像做平场校正
/// </summary>
public Mat Correct(Mat input)
{
// 转浮点
Mat inputF = new Mat();
input.ConvertTo(inputF, MatType.CV_32F);
// 减黑场
Mat subtracted = new Mat();
Cv2.Subtract(inputF, _darkField, subtracted);
// 乘增益图
Mat corrected = new Mat();
Cv2.Multiply(subtracted, _gainMap, corrected);
// 裁剪到合法范围并转回8位
Cv2.Min(corrected, 255, corrected);
Cv2.Max(corrected, 0, corrected);
Mat result = new Mat();
corrected.ConvertTo(result, MatType.CV_8UC1);
// 释放中间对象
inputF.Dispose();
subtracted.Dispose();
corrected.Dispose();
return result;
}
/// <summary>
/// 保存标定数据到文件
/// </summary>
public void SaveCalibration(string basePath)
{
// 保存白场、黑场、增益图,TIFF格式保留浮点精度
_whiteField.ConvertTo(_whiteField, MatType.CV_16U);
_whiteField.ImWrite(basePath + "_white.tif");
_darkField.ConvertTo(_darkField, MatType.CV_16U);
_darkField.ImWrite(basePath + "_dark.tif");
_gainMap.ImWrite(basePath + "_gain.tif");
Console.WriteLine($"标定数据已保存到: {basePath}_*.tif");
}
/// <summary>
/// 从文件加载标定数据
/// </summary>
public void LoadCalibration(string basePath)
{
_whiteField = Cv2.ImRead(basePath + "_white.tif", ImreadModes.Grayscale);
_darkField = Cv2.ImRead(basePath + "_dark.tif", ImreadModes.Grayscale);
_gainMap = Cv2.ImRead(basePath + "_gain.tif", ImreadModes.Unchanged);
Console.WriteLine("标定数据已加载");
}
}
// 实际使用流程
public void RunCorrection()
{
var corrector = new FlatFieldCorrector();
// 步骤1:产线停机时拍标定图
Mat whiteField = Cv2.ImRead("white_field.tif", ImreadModes.Grayscale);
Mat darkField = Cv2.ImRead("dark_field.tif", ImreadModes.Grayscale);
corrector.Calibrate(whiteField, darkField, 128.0);
// 步骤2:正常采集时逐帧校正
Mat rawImage = Cv2.ImRead("battery_el_001.tif", ImreadModes.Grayscale);
Mat corrected = corrector.Correct(rawImage);
// 保存结果
Cv2.ImWrite("battery_el_001_corrected.tif", corrected);
Cv2.ImWrite("battery_el_001_corrected.jpg", corrected,
new ImageEncodingParam(ImwriteFlags.JpegQuality, 95));
// 保存对比图------原图和校正后图水平拼接
Mat comparison = new Mat();
Cv2.HConcat(rawImage, corrected, comparison);
Cv2.PutText(comparison, "Original", new Point(10, 30),
HersheyFonts.HersheySimplex, 0.8, new Scalar(0, 255, 0), 2);
Cv2.PutText(comparison, "Corrected", new Point(rawImage.Width + 10, 30),
HersheyFonts.HersheySimplex, 0.8, new Scalar(0, 255, 0), 2);
Cv2.ImWrite("comparison_001.jpg", comparison);
}
白场怎么拍? 把均匀光源(积分球或者漫射板)放在相机视野内,光源亮度调到实际产线的平均水平,拍 10-50 张做平均,降低噪声。
黑场怎么拍? 盖上镜头盖或者关闭光源,同样拍 10-50 张平均。有些 sensor 有暗电流,黑场不是纯黑,必须减掉。
方法二:多帧平均降噪标定(提高精度)
白场和黑场拍摄时有随机噪声,单张做标定会把噪声也校正进去。多帧平均后再存,精度高很多。
csharp
public Mat CaptureAverageField(Func<Mat> captureFunc, int frameCount = 30)
{
// 采集第一帧初始化
Mat sum = new Mat();
captureFunc().ConvertTo(sum, MatType.CV_32F);
// 累加多帧
for (int i = 1; i < frameCount; i++)
{
using Mat frame = captureFunc();
Mat frameF = new Mat();
frame.ConvertTo(frameF, MatType.CV_32F);
Cv2.Add(sum, frameF, sum);
frameF.Dispose();
}
// 求平均
Cv2.Divide(sum, new Scalar(frameCount), sum);
Mat result = new Mat();
sum.ConvertTo(result, MatType.CV_8UC1);
sum.Dispose();
return result;
}
// 使用
Mat whiteField = CaptureAverageField(() =>
{
// 从相机采集一帧白场
return CaptureFromCamera();
}, 50);
// 保存平均后的标定图
Cv2.ImWrite("white_field_avg50.tif", whiteField);
注意:多帧平均时相机和光源必须绝对稳定,任何抖动都会让标定图糊掉。建议用三脚架固定,光源预热 30 分钟以上再拍。
方法三:分块自适应校正(应对非均匀光源)
有些场景光源不均匀不是固定模式,而是随时间缓慢变化(比如LED老化)。这时候可以拍一张"实时白场",或者用分块统计做自适应校正。
csharp
public Mat AdaptiveBlockCorrection(Mat input, int blockSize = 64)
{
Mat inputF = new Mat();
input.ConvertTo(inputF, MatType.CV_32F);
// 降采样计算局部均值(模拟实时白场)
Mat blurred = new Mat();
Cv2.Blur(inputF, blurred, new Size(blockSize, blockSize));
// 计算局部增益:目标灰度 / 局部均值
Mat localGain = new Mat();
Cv2.Divide(new Mat(blurred.Size(), MatType.CV_32F, new Scalar(128.0)),
blurred, localGain);
// 应用局部增益
Mat corrected = new Mat();
Cv2.Multiply(inputF, localGain, corrected);
// 裁剪并转回8位
Cv2.Min(corrected, 255, corrected);
Cv2.Max(corrected, 0, corrected);
Mat result = new Mat();
corrected.ConvertTo(result, MatType.CV_8UC1);
// 保存调试信息
blurred.ImWrite("debug_local_mean.tif");
localGain.ImWrite("debug_local_gain.tif");
inputF.Dispose();
blurred.Dispose();
localGain.Dispose();
corrected.Dispose();
return result;
}
这种方法的局限:只适用于"背景应该是均匀"的场景,比如光伏电池片、PCB 裸板。如果产品本身就有复杂的灰度变化,会把产品特征也"校正"掉。
三、彩色图的阴影校正
彩色图不能直接用灰度图的方法,需要分通道处理,或者转到 LAB 空间只校正 L 通道。
csharp
public Mat CorrectColorImage(Mat bgrInput)
{
// 方法1:分通道独立校正(适合Bayer相机各通道响应不均)
Mat[] channels = Cv2.Split(bgrInput);
// 每个通道有自己的白场和黑场
Mat bCorrected = CorrectSingleChannel(channels[0], whiteB, darkB);
Mat gCorrected = CorrectSingleChannel(channels[1], whiteG, darkG);
Mat rCorrected = CorrectSingleChannel(channels[2], whiteR, darkR);
Mat result = new Mat();
Cv2.Merge(new[] { bCorrected, gCorrected, rCorrected }, result);
// 保存各通道调试图
Cv2.ImWrite("debug_channel_b.tif", bCorrected);
Cv2.ImWrite("debug_channel_g.tif", gCorrected);
Cv2.ImWrite("debug_channel_r.tif", rCorrected);
return result;
}
// 方法2:LAB空间只校正亮度(适合光源色温不均但颜色本身没问题)
public Mat CorrectLabLChannel(Mat bgrInput)
{
// 转LAB
Mat lab = new Mat();
Cv2.CvtColor(bgrInput, lab, ColorConversionCodes.BGR2Lab);
// 分离通道
Mat[] channels = Cv2.Split(lab);
// 只对L通道做平场校正
Mat lCorrected = CorrectSingleChannel(channels[0], whiteL, darkL);
// 合并
Mat[] merged = { lCorrected, channels[1], channels[2] };
Mat labMerged = new Mat();
Cv2.Merge(merged, labMerged);
// 转回BGR
Mat result = new Mat();
Cv2.CvtColor(labMerged, result, ColorConversionCodes.Lab2BGR);
// 保存中间结果
Cv2.ImWrite("debug_lab_l_original.tif", channels[0]);
Cv2.ImWrite("debug_lab_l_corrected.tif", lCorrected);
Cv2.ImWrite("color_corrected_final.tif", result);
// 释放
foreach (var c in channels) c.Dispose();
lCorrected.Dispose();
labMerged.Dispose();
return result;
}
两种方法怎么选?
- 如果阴影是镜头渐晕 导致的(各通道衰减程度不同),用分通道校正
- 如果阴影是光源亮度不均 导致的(颜色本身没问题),用LAB空间只校正L通道
四、标定数据的保存与复用
产线相机一旦标定好,标定数据应该长期保存,换相机或者维护后重新标定。
csharp
public class CalibrationManager
{
private readonly string _calibDir;
public CalibrationManager(string calibDir)
{
_calibDir = calibDir;
Directory.CreateDirectory(calibDir);
}
/// <summary>
/// 保存完整标定包(含时间戳、相机SN、光源参数)
/// </summary>
public void SaveCalibrationPackage(string cameraSN, Mat white, Mat dark, Mat gain,
string lightSource, int exposureTime, double gainValue)
{
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string folder = Path.Combine(_calibDir, $"{cameraSN}_{timestamp}");
Directory.CreateDirectory(folder);
// 保存图像数据(TIFF保留精度)
white.ImWrite(Path.Combine(folder, "white_field.tif"));
dark.ImWrite(Path.Combine(folder, "dark_field.tif"));
gain.ImWrite(Path.Combine(folder, "gain_map.tif"));
// 保存元数据JSON
var meta = new
{
CameraSN = cameraSN,
CalibrationTime = timestamp,
LightSource = lightSource,
ExposureTimeUs = exposureTime,
Gain = gainValue,
ImageSize = new { white.Width, white.Height }
};
string json = System.Text.Json.JsonSerializer.Serialize(meta,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(Path.Combine(folder, "metadata.json"), json);
Console.WriteLine($"标定包已保存: {folder}");
}
/// <summary>
/// 加载最新标定包
/// </summary>
public bool LoadLatestCalibration(string cameraSN, out Mat white, out Mat dark, out Mat gain)
{
white = null; dark = null; gain = null;
var dirs = Directory.GetDirectories(_calibDir, $"{cameraSN}_*")
.OrderByDescending(d => d)
.ToArray();
if (dirs.Length == 0) return false;
string latest = dirs[0];
white = Cv2.ImRead(Path.Combine(latest, "white_field.tif"), ImreadModes.Grayscale);
dark = Cv2.ImRead(Path.Combine(latest, "dark_field.tif"), ImreadModes.Grayscale);
gain = Cv2.ImRead(Path.Combine(latest, "gain_map.tif"), ImreadModes.Unchanged);
Console.WriteLine($"已加载标定包: {latest}");
return true;
}
}
五、几个容易踩的坑
坑一:白场拍的时候光源不均匀
标定的前提是"白场本身是均匀的"。如果白场图就有阴影,校正后会把阴影反向叠加到正常图上。建议用积分球或者至少用漫射板,别直接拍光源。
坑二:标定后产品移动导致光照变化
平场校正只解决固定模式的阴影,如果产品是反光的、位置变化导致高光点移动,校正不了。这种场景得靠光源角度设计或者偏振片。
坑三:16位图直接转8位做校正
工业相机很多输出 10bit、12bit 或 16bit,直接转 8bit 会损失精度,校正后可能出现条带。应该在浮点空间做完整计算,最后按需转 8bit。
csharp
// 错误:先转8位再校正
Mat wrong = Cv2.ImRead("16bit.tif", ImreadModes.Grayscale); // 默认转8位了!
// 正确:保留16位精度
Mat correct = new Mat();
correct = Cv2.ImRead("16bit.tif", ImreadModes.Unchanged); // 保留原始深度
correct.ConvertTo(correct, MatType.CV_32F); // 转浮点做计算
坑四:增益图有异常值导致校正后噪点爆炸
如果白场某个像素因为灰尘或者坏点特别暗,对应的增益值会特别大,校正后那个位置全是噪点。需要做中值滤波或者异常值剔除:
csharp
// 对增益图做中值滤波,消除孤立异常点
Mat gainFiltered = new Mat();
Cv2.MedianBlur(_gainMap, gainFiltered, 5);
_gainMap.Dispose();
_gainMap = gainFiltered;
// 保存滤波前后的对比
_gainMap.ImWrite("gain_map_filtered.tif");
坑五:忘了定期重新标定
LED 光源会老化,色温和亮度都会漂移。建议每个月重新拍一次白场,对比新旧增益图的差异,超过阈值就重新标定。
六、实际效果对比
放一组实际数据(推文里配图):
| 指标 | 校正前 | 校正后 |
|---|---|---|
| 四角亮度差异 | ±18% | ±2.1% |
| 中心-边缘一致性 | 82% | 97.5% |
| 缺陷检出率(EL隐裂) | 89% | 96.3% |
| 误报率 | 12% | 4.7% |
阴影校正不是锦上添花,是缺陷检测准确率的基础。亮度不均匀时,阈值分割要么把阴影区当缺陷,要么把真实缺陷漏掉。
七、总结
阴影平场校正这事,核心就三点:
- 标定要准------白场、黑场多帧平均,光源稳定后再拍
- 保存要全------标定数据、元数据、调试中间图,一个都不能少
- 定期复检------光源老化、相机维护后必须重新标定
小提示:如果产线有四台以上相机,建议做统一标定------用同一张白场图分别给各台相机拍,确保它们的响应曲线一致。拼接后的图才不会出现分界线。
本文代码在 OpenCvSharp 4.8 + .NET 6 环境下测试通过。相机为海康 MV-CA013-20GM,光源为白色 LED 条形光 + 漫射板。