C# 相机图像阴影校正:使用OpenCvSharp实现工业相机阴影平场校正功能

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%

阴影校正不是锦上添花,是缺陷检测准确率的基础。亮度不均匀时,阈值分割要么把阴影区当缺陷,要么把真实缺陷漏掉。


七、总结

阴影平场校正这事,核心就三点:

  1. 标定要准------白场、黑场多帧平均,光源稳定后再拍
  2. 保存要全------标定数据、元数据、调试中间图,一个都不能少
  3. 定期复检------光源老化、相机维护后必须重新标定

小提示:如果产线有四台以上相机,建议做统一标定------用同一张白场图分别给各台相机拍,确保它们的响应曲线一致。拼接后的图才不会出现分界线。


本文代码在 OpenCvSharp 4.8 + .NET 6 环境下测试通过。相机为海康 MV-CA013-20GM,光源为白色 LED 条形光 + 漫射板。

相关推荐
桃西西呀1 小时前
9月1日起AI图要被标记了,机器怎么一眼认出哪张是 AI 画的?
人工智能·机器学习·llm
招财小梗1 小时前
沈阳AI企业咨询可定制数字化方案吗?
大数据·人工智能·python
其实防守也摸鱼1 小时前
教育信息技术应用创新---基础软件信息赛(题库)
大数据·运维·人工智能·web安全·自动化
Zzj_tju1 小时前
Prompt Injection 防御:隔离不可信上下文的最小复现
人工智能·深度学习·机器学习·自然语言处理·prompt
合合技术团队1 小时前
论文解读|合合信息与上海交通大学打造DocIQ模型,AI为文档图像质量“做体检”
人工智能·计算机视觉
Superzhangaa1 小时前
太希智能全栈自研背后的机器人逻辑
人工智能·机器人·开源
枫叶丹41 小时前
小模型与本地 Agent:不是更小的替代品,而是新的系统分工
人工智能·chatgpt·agent·codex
空堂与归1 小时前
RNN记不住长序列怎么办?用 LSTM 三门一通道接旁路
人工智能·rnn·nlp·lstm
小白的后端世界1 小时前
跨境电商数据分析与 AI Agent 自动化:从指标体系到决策闭环
人工智能·深度学习·数据分析·自动化