// ============================================================================ // Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. // 文件? BgaVoidRateProcessor.cs // 描述: BGA 空洞率检测算子(两步自动检测法? // // 处理流程: // 第一??焊球定位: 高斯模糊 ?Otsu反向二值化 ?闭运??轮廓检??圆度过滤 ?椭圆拟合 // 第二??气泡检? 焊球轮廓掩码 ?双阈值分??轮廓检??面积过滤 ?气泡率计? // // 支持多边形ROI限定检测区域,支持IPC-7095标准PASS/FAIL判定 // 正片模式:焊?暗区域,气泡=亮区? // // 作? 李伟 wei.lw.li@hexagon.com // ============================================================================ using Emgu.CV; using Emgu.CV.CvEnum; using Emgu.CV.Structure; using Emgu.CV.Util; using Serilog; using System.Drawing; using XP.ImageProcessing.Core; namespace XP.ImageProcessing.Processors; public class BgaVoidRateProcessor : ImageProcessorBase { private static readonly ILogger _logger = Log.ForContext(); public BgaVoidRateProcessor() { Name = LocalizationHelper.GetString("BgaVoidRateProcessor_Name"); Description = LocalizationHelper.GetString("BgaVoidRateProcessor_Description"); } protected override void InitializeParameters() { // ── ROI限定区域 ── Parameters.Add("RoiMode", new ProcessorParameter( "RoiMode", LocalizationHelper.GetString("BgaVoidRateProcessor_RoiMode"), typeof(string), "None", null, null, LocalizationHelper.GetString("BgaVoidRateProcessor_RoiMode_Desc"), new string[] { "None", "Polygon" })); // 多边形ROI点数和坐标(由UI注入,不可见,最多支?2个点? Parameters.Add("PolyCount", new ProcessorParameter("PolyCount", "PolyCount", typeof(int), 0, null, null, "") { IsVisible = false }); for (int i = 0; i < 32; i++) { Parameters.Add($"PolyX{i}", new ProcessorParameter($"PolyX{i}", $"PolyX{i}", typeof(int), 0, null, null, "") { IsVisible = false }); Parameters.Add($"PolyY{i}", new ProcessorParameter($"PolyY{i}", $"PolyY{i}", typeof(int), 0, null, null, "") { IsVisible = false }); } // ── 第一步:BGA定位参数 ── Parameters.Add("BgaMinArea", new ProcessorParameter( "BgaMinArea", LocalizationHelper.GetString("BgaVoidRateProcessor_BgaMinArea"), typeof(int), 500, 10, 1000000, LocalizationHelper.GetString("BgaVoidRateProcessor_BgaMinArea_Desc"))); Parameters.Add("BgaMaxArea", new ProcessorParameter( "BgaMaxArea", LocalizationHelper.GetString("BgaVoidRateProcessor_BgaMaxArea"), typeof(int), 500000, 100, 10000000, LocalizationHelper.GetString("BgaVoidRateProcessor_BgaMaxArea_Desc"))); Parameters.Add("BgaBlurSize", new ProcessorParameter( "BgaBlurSize", LocalizationHelper.GetString("BgaVoidRateProcessor_BgaBlurSize"), typeof(int), 5, 1, 31, LocalizationHelper.GetString("BgaVoidRateProcessor_BgaBlurSize_Desc"))); Parameters.Add("BgaCircularity", new ProcessorParameter( "BgaCircularity", LocalizationHelper.GetString("BgaVoidRateProcessor_BgaCircularity"), typeof(double), 0.5, 0.0, 1.0, LocalizationHelper.GetString("BgaVoidRateProcessor_BgaCircularity_Desc"))); // ── 第二步:气泡检测参?── Parameters.Add("MinThreshold", new ProcessorParameter( "MinThreshold", LocalizationHelper.GetString("BgaVoidRateProcessor_MinThreshold"), typeof(int), 128, 0, 255, LocalizationHelper.GetString("BgaVoidRateProcessor_MinThreshold_Desc"))); Parameters.Add("MaxThreshold", new ProcessorParameter( "MaxThreshold", LocalizationHelper.GetString("BgaVoidRateProcessor_MaxThreshold"), typeof(int), 255, 0, 255, LocalizationHelper.GetString("BgaVoidRateProcessor_MaxThreshold_Desc"))); Parameters.Add("MinVoidArea", new ProcessorParameter( "MinVoidArea", LocalizationHelper.GetString("BgaVoidRateProcessor_MinVoidArea"), typeof(int), 10, 1, 10000, LocalizationHelper.GetString("BgaVoidRateProcessor_MinVoidArea_Desc"))); Parameters.Add("VoidLimit", new ProcessorParameter( "VoidLimit", LocalizationHelper.GetString("BgaVoidRateProcessor_VoidLimit"), typeof(double), 25.0, 0.0, 100.0, LocalizationHelper.GetString("BgaVoidRateProcessor_VoidLimit_Desc"))); Parameters.Add("Thickness", new ProcessorParameter( "Thickness", LocalizationHelper.GetString("BgaVoidRateProcessor_Thickness"), typeof(int), 2, 1, 10, LocalizationHelper.GetString("BgaVoidRateProcessor_Thickness_Desc"))); } public override Image Process(Image inputImage) { string roiMode = GetParameter("RoiMode"); int bgaMinArea = GetParameter("BgaMinArea"); int bgaMaxArea = GetParameter("BgaMaxArea"); int bgaBlurSize = GetParameter("BgaBlurSize"); double bgaCircularity = GetParameter("BgaCircularity"); int minThresh = GetParameter("MinThreshold"); int maxThresh = GetParameter("MaxThreshold"); int minVoidArea = GetParameter("MinVoidArea"); double voidLimit = GetParameter("VoidLimit"); int thickness = GetParameter("Thickness"); // 确保模糊核为奇数 if (bgaBlurSize % 2 == 0) bgaBlurSize++; OutputData.Clear(); int w = inputImage.Width, h = inputImage.Height; // 构建ROI掩码(限定检测区域) Image? roiMask = null; if (roiMode == "Polygon") { int polyCount = GetParameter("PolyCount"); if (polyCount >= 3) { var pts = new Point[polyCount]; for (int i = 0; i < polyCount; i++) pts[i] = new Point(GetParameter($"PolyX{i}"), GetParameter($"PolyY{i}")); roiMask = new Image(w, h); using var vop = new VectorOfPoint(pts); using var vvop = new VectorOfVectorOfPoint(vop); CvInvoke.DrawContours(roiMask, vvop, 0, new MCvScalar(255), -1); _logger.Debug("ROI Polygon: {Count} points", polyCount); } } OutputData["RoiMode"] = roiMode; OutputData["RoiMask"] = roiMask; _logger.Debug("BgaVoidRate 两步? BgaArea=[{Min},{Max}], Blur={Blur}, Circ={Circ}, Thresh=[{TMin},{TMax}]", bgaMinArea, bgaMaxArea, bgaBlurSize, bgaCircularity, minThresh, maxThresh); // ================================================================ // 第一步:自动检测BGA焊球位置 // ================================================================ var bgaResults = DetectBgaBalls(inputImage, bgaBlurSize, bgaMinArea, bgaMaxArea, bgaCircularity, roiMask); _logger.Information("第一步完? 检测到 {Count} 个BGA焊球", bgaResults.Count); if (bgaResults.Count == 0) { OutputData["BgaVoidResult"] = true; OutputData["BgaCount"] = 0; OutputData["BgaBalls"] = bgaResults; OutputData["VoidRate"] = 0.0; OutputData["Classification"] = "N/A"; OutputData["ResultText"] = "No BGA detected"; OutputData["Thickness"] = thickness; OutputData["VoidLimit"] = voidLimit; OutputData["TotalBgaArea"] = 0; OutputData["TotalVoidArea"] = 0; OutputData["TotalVoidCount"] = 0; roiMask?.Dispose(); return inputImage.Clone(); } // ================================================================ // 第二步:在每个焊球区域内检测气? // ================================================================ int totalBgaArea = 0; int totalVoidArea = 0; int totalVoidCount = 0; foreach (var bga in bgaResults) { DetectVoidsInBga(inputImage, bga, minThresh, maxThresh, minVoidArea); totalBgaArea += bga.BgaArea; totalVoidArea += bga.VoidPixels; totalVoidCount += bga.Voids.Count; } double overallVoidRate = totalBgaArea > 0 ? (double)totalVoidArea / totalBgaArea * 100.0 : 0; string classification = overallVoidRate <= voidLimit ? "PASS" : "FAIL"; // 检查每个焊球是否单独超? foreach (var bga in bgaResults) { bga.Classification = bga.VoidRate <= voidLimit ? "PASS" : "FAIL"; } _logger.Information("第二步完? 总气泡率={VoidRate:F1}%, 气泡?{Count}, 判定={Class}", overallVoidRate, totalVoidCount, classification); // 输出数据 OutputData["BgaVoidResult"] = true; OutputData["BgaCount"] = bgaResults.Count; OutputData["BgaBalls"] = bgaResults; OutputData["VoidRate"] = overallVoidRate; OutputData["FillRate"] = 100.0 - overallVoidRate; OutputData["TotalBgaArea"] = totalBgaArea; OutputData["TotalVoidArea"] = totalVoidArea; OutputData["TotalVoidCount"] = totalVoidCount; OutputData["VoidLimit"] = voidLimit; OutputData["Classification"] = classification; OutputData["Thickness"] = thickness; OutputData["ResultText"] = $"Void: {overallVoidRate:F1}% | {classification} | BGA×{bgaResults.Count}"; roiMask?.Dispose(); return inputImage.Clone(); } /// /// 第一步:自动检测BGA焊球位置 /// 使用Otsu二值化 + 轮廓检?+ 圆度过滤 + 椭圆拟合 /// private List DetectBgaBalls(Image input, int blurSize, int minArea, int maxArea, double minCircularity, Image? roiMask) { var results = new List(); int w = input.Width, h = input.Height; // 高斯模糊降噪 var blurred = new Image(w, h); CvInvoke.GaussianBlur(input, blurred, new Size(blurSize, blurSize), 0); // Otsu自动二值化(X-Ray正片:焊?暗区域) var binary = new Image(w, h); CvInvoke.Threshold(blurred, binary, 0, 255, ThresholdType.Otsu | ThresholdType.BinaryInv); // 如果有ROI掩码,只保留ROI区域内的二值化结果 if (roiMask != null) { CvInvoke.BitwiseAnd(binary, roiMask, binary); } // 形态学闭运算填充小孔洞 var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse, new Size(5, 5), new Point(-1, -1)); CvInvoke.MorphologyEx(binary, binary, MorphOp.Close, kernel, new Point(-1, -1), 2, BorderType.Default, new MCvScalar(0)); // 查找轮廓 using var contours = new VectorOfVectorOfPoint(); using var hierarchy = new Mat(); CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); int bgaIndex = 0; for (int i = 0; i < contours.Size; i++) { double area = CvInvoke.ContourArea(contours[i]); if (area < minArea || area > maxArea) continue; // 圆度过滤: circularity = 4π × area / perimeter² double perimeter = CvInvoke.ArcLength(contours[i], true); if (perimeter < 1) continue; double circularity = 4.0 * Math.PI * area / (perimeter * perimeter); if (circularity < minCircularity) continue; // 需要至?个点才能拟合椭圆 if (contours[i].Size < 5) continue; var ellipse = CvInvoke.FitEllipse(contours[i]); var moments = CvInvoke.Moments(contours[i]); if (moments.M00 < 1) continue; bgaIndex++; results.Add(new BgaBallInfo { Index = bgaIndex, CenterX = moments.M10 / moments.M00, CenterY = moments.M01 / moments.M00, FittedEllipse = ellipse, ContourPoints = contours[i].ToArray(), BgaArea = (int)area, Circularity = circularity }); } // 按面积从大到小排? results.Sort((a, b) => b.BgaArea.CompareTo(a.BgaArea)); for (int i = 0; i < results.Count; i++) results[i].Index = i + 1; blurred.Dispose(); binary.Dispose(); kernel.Dispose(); return results; } /// /// 第二步:在单个BGA焊球区域内检测气? /// 使用焊球轮廓作为掩码,双阈值分割气泡区? /// private void DetectVoidsInBga(Image input, BgaBallInfo bga, int minThresh, int maxThresh, int minVoidArea) { int w = input.Width, h = input.Height; // 创建该焊球的掩码 var mask = new Image(w, h); using (var vop = new VectorOfPoint(bga.ContourPoints)) using (var vvop = new VectorOfVectorOfPoint(vop)) { CvInvoke.DrawContours(mask, vvop, 0, new MCvScalar(255), -1); } int bgaPixels = CvInvoke.CountNonZero(mask); bga.BgaArea = bgaPixels; // 双阈值分割(正片模式:气?亮,灰度在[minThresh, maxThresh]范围内判为气泡) var voidImg = new Image(w, h); byte[,,] srcData = input.Data; byte[,,] dstData = voidImg.Data; byte[,,] maskData = mask.Data; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { if (maskData[y, x, 0] > 0) { byte val = srcData[y, x, 0]; dstData[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0; } } } int voidPixels = CvInvoke.CountNonZero(voidImg); bga.VoidPixels = voidPixels; bga.VoidRate = bgaPixels > 0 ? (double)voidPixels / bgaPixels * 100.0 : 0; // 检测每个气泡的轮廓 using var contours = new VectorOfVectorOfPoint(); using var hierarchy = new Mat(); CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); for (int i = 0; i < contours.Size; i++) { double area = CvInvoke.ContourArea(contours[i]); if (area < minVoidArea) continue; var moments = CvInvoke.Moments(contours[i]); if (moments.M00 < 1) continue; bga.Voids.Add(new VoidInfo { Index = bga.Voids.Count + 1, CenterX = moments.M10 / moments.M00, CenterY = moments.M01 / moments.M00, Area = area, AreaPercent = bgaPixels > 0 ? area / bgaPixels * 100.0 : 0, BoundingBox = CvInvoke.BoundingRectangle(contours[i]), ContourPoints = contours[i].ToArray() }); } // 按面积从大到小排? bga.Voids.Sort((a, b) => b.Area.CompareTo(a.Area)); for (int i = 0; i < bga.Voids.Count; i++) bga.Voids[i].Index = i + 1; mask.Dispose(); voidImg.Dispose(); } } /// /// 单个BGA焊球信息 /// public class BgaBallInfo { public int Index { get; set; } public double CenterX { get; set; } public double CenterY { get; set; } public RotatedRect FittedEllipse { get; set; } public Point[] ContourPoints { get; set; } = Array.Empty(); public int BgaArea { get; set; } public double Circularity { get; set; } public int VoidPixels { get; set; } public double VoidRate { get; set; } public string Classification { get; set; } = "N/A"; public List Voids { get; set; } = new(); } /// /// 单个气泡信息 /// public class VoidInfo { public int Index { get; set; } public double CenterX { get; set; } public double CenterY { get; set; } public double Area { get; set; } public double AreaPercent { get; set; } public Rectangle BoundingBox { get; set; } public Point[] ContourPoints { get; set; } = Array.Empty(); }