// ============================================================================ // Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. // 文件名: QfnLeadPadVoidProcessor.cs // 描述: QFN 单引脚空洞率检测算子 // // 处理流程: // 第一步 — 引脚定位: 高斯模糊 → 双阈值分割 → 形态学闭运算 → 轮廓检测 // → 面积/长宽比过滤 → 排除散热焊盘 → 引脚排序 // 第二步 — 空洞检测: 逐引脚掩码 → 双阈值分割 → 轮廓检测 → 面积过滤 → 空洞率计算 // // 支持多边形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 XP.ImageProcessing.Core; using Serilog; using System.Drawing; namespace XP.ImageProcessing.Processors; public class QfnLeadPadVoidProcessor : ImageProcessorBase { private static readonly ILogger _logger = Log.ForContext(); public QfnLeadPadVoidProcessor() { Name = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Name"); Description = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Description"); } protected override void InitializeParameters() { // ── ROI限定区域 ── Parameters.Add("RoiMode", new ProcessorParameter( "RoiMode", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode"), typeof(string), "None", null, null, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode_Desc"), new string[] { "None", "Polygon" })); // 多边形ROI点数和坐标(由UI注入,不可见,最多支持32个点) 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 }); } // ── 第一步:引脚定位参数 ── Parameters.Add("PadBlurSize", new ProcessorParameter( "PadBlurSize", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize"), typeof(int), 5, 1, 31, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize_Desc"))); Parameters.Add("PadThresholdLow", new ProcessorParameter( "PadThresholdLow", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow"), typeof(int), 0, 0, 255, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow_Desc"))); Parameters.Add("PadThresholdHigh", new ProcessorParameter( "PadThresholdHigh", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh"), typeof(int), 120, 0, 255, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh_Desc"))); Parameters.Add("PadMorphKernel", new ProcessorParameter( "PadMorphKernel", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel"), typeof(int), 5, 1, 31, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel_Desc"))); Parameters.Add("MinPadArea", new ProcessorParameter( "MinPadArea", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea"), typeof(int), 200, 10, 1000000, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea_Desc"))); Parameters.Add("MaxPadArea", new ProcessorParameter( "MaxPadArea", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea"), typeof(int), 100000, 100, 10000000, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea_Desc"))); Parameters.Add("PadAspectRatioMin", new ProcessorParameter( "PadAspectRatioMin", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin"), typeof(double), 1.2, 0.1, 20.0, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc"))); // ── 第二步:空洞检测参数 ── Parameters.Add("VoidThresholdLow", new ProcessorParameter( "VoidThresholdLow", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow"), typeof(int), 128, 0, 255, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow_Desc"))); Parameters.Add("VoidThresholdHigh", new ProcessorParameter( "VoidThresholdHigh", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh"), typeof(int), 255, 0, 255, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc"))); Parameters.Add("MinVoidArea", new ProcessorParameter( "MinVoidArea", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea"), typeof(int), 5, 1, 10000, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea_Desc"))); Parameters.Add("VoidMergeRadius", new ProcessorParameter( "VoidMergeRadius", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius"), typeof(int), 2, 0, 20, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius_Desc"))); Parameters.Add("VoidRateLimit", new ProcessorParameter( "VoidRateLimit", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit"), typeof(double), 50.0, 0.0, 100.0, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit_Desc"))); Parameters.Add("MinQualifiedPadArea", new ProcessorParameter( "MinQualifiedPadArea", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea"), typeof(int), 1000, 0, 1000000, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc"))); Parameters.Add("Thickness", new ProcessorParameter( "Thickness", LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness"), typeof(int), 2, 1, 10, LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness_Desc"))); } public override Image Process(Image inputImage) { // 读取参数 string roiMode = GetParameter("RoiMode"); int padBlurSize = GetParameter("PadBlurSize"); int padThreshLow = GetParameter("PadThresholdLow"); int padThreshHigh = GetParameter("PadThresholdHigh"); int padMorphKernel = GetParameter("PadMorphKernel"); int minPadArea = GetParameter("MinPadArea"); int maxPadArea = GetParameter("MaxPadArea"); double padAspectRatioMin = GetParameter("PadAspectRatioMin"); int voidThreshLow = GetParameter("VoidThresholdLow"); int voidThreshHigh = GetParameter("VoidThresholdHigh"); int minVoidArea = GetParameter("MinVoidArea"); int voidMergeRadius = GetParameter("VoidMergeRadius"); double voidRateLimit = GetParameter("VoidRateLimit"); int minQualifiedPadArea = GetParameter("MinQualifiedPadArea"); int thickness = GetParameter("Thickness"); // 确保模糊核为奇数 if (padBlurSize % 2 == 0) padBlurSize++; if (padMorphKernel % 2 == 0) padMorphKernel++; 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("QFN Lead ROI Polygon: {Count} points", polyCount); } } OutputData["RoiMode"] = roiMode; OutputData["RoiMask"] = roiMask; _logger.Debug("QfnLeadPadVoid: PadArea=[{Min},{Max}], Blur={Blur}, PadThresh=[{TLow},{THigh}], AspectMin={Asp}", minPadArea, maxPadArea, padBlurSize, padThreshLow, padThreshHigh, padAspectRatioMin); // ================================================================ // 第一步:自动检测QFN引脚焊点位置 // ================================================================ var leadPads = DetectLeadPads(inputImage, padBlurSize, padThreshLow, padThreshHigh, padMorphKernel, minPadArea, maxPadArea, padAspectRatioMin, roiMask); _logger.Information("第一步完成: 检测到 {Count} 个QFN引脚焊点", leadPads.Count); if (leadPads.Count == 0) { OutputData["QfnLeadResult"] = true; OutputData["LeadCount"] = 0; OutputData["LeadPads"] = leadPads; OutputData["VoidRate"] = 0.0; OutputData["VoidRateLimit"] = voidRateLimit; OutputData["Classification"] = "N/A"; OutputData["ResultText"] = "No QFN lead pads detected"; OutputData["Thickness"] = thickness; OutputData["TotalPadArea"] = 0; OutputData["TotalVoidArea"] = 0; OutputData["TotalVoidCount"] = 0; roiMask?.Dispose(); return inputImage.Clone(); } // ================================================================ // 第二步:在每个引脚焊点区域内检测空洞 // ================================================================ int totalPadArea = 0; int totalVoidArea = 0; int totalVoidCount = 0; foreach (var pad in leadPads) { DetectVoidsInLeadPad(inputImage, pad, voidThreshLow, voidThreshHigh, minVoidArea, voidMergeRadius); totalPadArea += pad.PadArea; totalVoidArea += pad.VoidPixels; totalVoidCount += pad.Voids.Count; } double overallVoidRate = totalPadArea > 0 ? (double)totalVoidArea / totalPadArea * 100.0 : 0; string classification = "PASS"; // 判定:空洞率超标或面积不足均为FAIL int failCount = 0; double maxSingleVoidRate = 0; foreach (var pad in leadPads) { if (pad.PadArea < minQualifiedPadArea) pad.Classification = "FAIL_AREA"; else if (pad.VoidRate > voidRateLimit) pad.Classification = "FAIL"; else pad.Classification = "PASS"; if (pad.Classification != "PASS") failCount++; if (pad.VoidRate > maxSingleVoidRate) maxSingleVoidRate = pad.VoidRate; } if (failCount > 0) classification = "FAIL"; _logger.Information("第二步完成: 总空洞率={VoidRate:F1}%, 最大单引脚={MaxRate:F1}%, 不合格={Fail}/{Total}, 判定={Class}", overallVoidRate, maxSingleVoidRate, failCount, leadPads.Count, classification); // ── 输出数据 ── OutputData["QfnLeadResult"] = true; OutputData["LeadCount"] = leadPads.Count; OutputData["LeadPads"] = leadPads; OutputData["VoidRate"] = overallVoidRate; OutputData["MaxSingleVoidRate"] = maxSingleVoidRate; OutputData["VoidRateLimit"] = voidRateLimit; OutputData["TotalPadArea"] = totalPadArea; OutputData["TotalVoidArea"] = totalVoidArea; OutputData["TotalVoidCount"] = totalVoidCount; OutputData["FailCount"] = failCount; OutputData["Classification"] = classification; OutputData["Thickness"] = thickness; OutputData["ResultText"] = $"QFN Lead: {overallVoidRate:F1}% | {classification} | {leadPads.Count} pads | Fail: {failCount}"; roiMask?.Dispose(); return inputImage.Clone(); } /// /// 第一步:自动检测QFN引脚焊点位置 /// 使用双阈值分割 + 形态学 + 轮廓检测 + 面积/长宽比过滤 /// QFN引脚在X-Ray正片中为暗色长条形区域 /// private List DetectLeadPads( Image input, int blurSize, int threshLow, int threshHigh, int morphKernel, int minArea, int maxArea, double aspectRatioMin, 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); // 双阈值分割(X-Ray正片:焊点=暗区域,灰度在[threshLow, threshHigh]范围内判为焊点) var binary = new Image(w, h); unsafe { byte* srcPtr = (byte*)blurred.Mat.DataPointer; byte* dstPtr = (byte*)binary.Mat.DataPointer; int srcStep = blurred.Mat.Step; int dstStep = binary.Mat.Step; for (int y = 0; y < h; y++) { byte* srcRow = srcPtr + y * srcStep; byte* dstRow = dstPtr + y * dstStep; for (int x = 0; x < w; x++) { byte val = srcRow[x]; dstRow[x] = (val >= threshLow && val <= threshHigh) ? (byte)255 : (byte)0; } } } // 如果有ROI掩码,只保留ROI区域内的结果 if (roiMask != null) { CvInvoke.BitwiseAnd(binary, roiMask, binary); } // 形态学闭运算填充引脚内部小孔洞 using var kernel = CvInvoke.GetStructuringElement(ElementShape.Rectangle, new Size(morphKernel, morphKernel), 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); for (int i = 0; i < contours.Size; i++) { double area = CvInvoke.ContourArea(contours[i]); if (area < minArea || area > maxArea) continue; // 需要至少5个点才能拟合椭圆/旋转矩形 if (contours[i].Size < 5) continue; // 最小外接旋转矩形 var minRect = CvInvoke.MinAreaRect(contours[i]); float rectWidth = Math.Max(minRect.Size.Width, minRect.Size.Height); float rectHeight = Math.Min(minRect.Size.Width, minRect.Size.Height); // 长宽比过滤:QFN引脚是长条形,长宽比应大于阈值 if (rectHeight < 1) continue; double aspectRatio = rectWidth / rectHeight; if (aspectRatio < aspectRatioMin) continue; var moments = CvInvoke.Moments(contours[i]); if (moments.M00 < 1) continue; results.Add(new QfnLeadPadInfo { CenterX = moments.M10 / moments.M00, CenterY = moments.M01 / moments.M00, BoundingRotatedRect = minRect, ContourPoints = contours[i].ToArray(), PadArea = (int)area, AspectRatio = aspectRatio }); } // 按角度位置排序(从图像中心出发,逆时针排列) SortLeadPadsByPosition(results, w, h); blurred.Dispose(); binary.Dispose(); return results; } /// /// 按引脚在图像中的位置排序(从左上角开始逆时针) /// 先按所在边分组(上/右/下/左),再在每条边内按位置排序 /// private void SortLeadPadsByPosition(List pads, int imageWidth, int imageHeight) { if (pads.Count == 0) return; double cx = imageWidth / 2.0; double cy = imageHeight / 2.0; // 按角度排序(从正上方开始顺时针) // atan2 返回 [-π, π],调整为从正上方开始 pads.Sort((a, b) => { double angleA = Math.Atan2(a.CenterX - cx, -(a.CenterY - cy)); double angleB = Math.Atan2(b.CenterX - cx, -(b.CenterY - cy)); if (angleA < 0) angleA += 2 * Math.PI; if (angleB < 0) angleB += 2 * Math.PI; return angleA.CompareTo(angleB); }); // 重新编号 for (int i = 0; i < pads.Count; i++) pads[i].Index = i + 1; } /// /// 第二步:在单个引脚焊点区域内检测空洞 /// 使用引脚轮廓作为掩码,双阈值分割空洞区域 /// private void DetectVoidsInLeadPad( Image input, QfnLeadPadInfo pad, int voidThreshLow, int voidThreshHigh, int minVoidArea, int mergeRadius) { int w = input.Width, h = input.Height; // 创建该引脚的掩码 var mask = new Image(w, h); using (var vop = new VectorOfPoint(pad.ContourPoints)) using (var vvop = new VectorOfVectorOfPoint(vop)) { CvInvoke.DrawContours(mask, vvop, 0, new MCvScalar(255), -1); } int padPixels = CvInvoke.CountNonZero(mask); pad.PadArea = padPixels; // 双阈值分割(正片模式:空洞=亮区域,灰度在[voidThreshLow, voidThreshHigh]范围内判为空洞) var voidImg = new Image(w, h); unsafe { byte* srcPtr = (byte*)input.Mat.DataPointer; byte* dstPtr = (byte*)voidImg.Mat.DataPointer; byte* mskPtr = (byte*)mask.Mat.DataPointer; int srcStep = input.Mat.Step; int dstStep = voidImg.Mat.Step; int mskStep = mask.Mat.Step; for (int y = 0; y < h; y++) { byte* srcRow = srcPtr + y * srcStep; byte* dstRow = dstPtr + y * dstStep; byte* mskRow = mskPtr + y * mskStep; for (int x = 0; x < w; x++) { if (mskRow[x] > 0) { byte val = srcRow[x]; dstRow[x] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0; } } } } // 形态学膨胀合并相邻空洞 if (mergeRadius > 0) { int kernelSize = mergeRadius * 2 + 1; using var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse, new Size(kernelSize, kernelSize), new Point(-1, -1)); CvInvoke.Dilate(voidImg, voidImg, kernel, new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0)); // 与引脚掩码取交集,防止膨胀超出引脚区域 CvInvoke.BitwiseAnd(voidImg, mask, voidImg); } // 检测每个空洞的轮廓 using var contours = new VectorOfVectorOfPoint(); using var hierarchy = new Mat(); CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); int filteredVoidArea = 0; 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; filteredVoidArea += (int)Math.Round(area); pad.Voids.Add(new QfnLeadVoidInfo { Index = pad.Voids.Count + 1, CenterX = moments.M10 / moments.M00, CenterY = moments.M01 / moments.M00, Area = area, AreaPercent = padPixels > 0 ? area / padPixels * 100.0 : 0, BoundingBox = CvInvoke.BoundingRectangle(contours[i]), ContourPoints = contours[i].ToArray() }); } // 空洞率基于过滤后的轮廓面积计算 pad.VoidPixels = filteredVoidArea; pad.VoidRate = padPixels > 0 ? (double)filteredVoidArea / padPixels * 100.0 : 0; // 按面积从大到小排序 pad.Voids.Sort((a, b) => b.Area.CompareTo(a.Area)); for (int i = 0; i < pad.Voids.Count; i++) pad.Voids[i].Index = i + 1; mask.Dispose(); voidImg.Dispose(); } } /// /// 单个QFN引脚焊点信息 /// public class QfnLeadPadInfo { public int Index { get; set; } public double CenterX { get; set; } public double CenterY { get; set; } public RotatedRect BoundingRotatedRect { get; set; } public Point[] ContourPoints { get; set; } = Array.Empty(); public int PadArea { get; set; } public double AspectRatio { 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 QfnLeadVoidInfo { 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(); }