合并 Develop/XP(PR 200: 气泡检测算法更新)

This commit is contained in:
XplorePlane Developer
2026-07-24 18:53:53 +08:00
12 changed files with 770 additions and 192 deletions
@@ -101,6 +101,11 @@ public static class PixelDepthHelper
result.Data[y, x, 0] = (byte)Math.Clamp((int)((src.Data[y, x, 0] - minV) / range * 255.0), 0, 255);
});
}
else
{
byte v = (byte)Math.Clamp((int)Math.Round(minV), 0, 255);
result.SetValue(new Gray(v));
}
return (result as Image<Gray, TDepth>)!;
}
else
@@ -114,6 +119,11 @@ public static class PixelDepthHelper
result.Data[y, x, 0] = (ushort)Math.Clamp((int)((src.Data[y, x, 0] - minV) / range * 65535.0), 0, 65535);
});
}
else
{
ushort v = (ushort)Math.Clamp((int)Math.Round(minV), 0, 65535);
result.SetValue(new Gray(v));
}
return (result as Image<Gray, TDepth>)!;
}
}
@@ -54,13 +54,21 @@ public class GrayscaleProcessor<TDepth> : ImageProcessorBase<TDepth>
if (method == "Max")
{
var f = result.Convert<Gray, float>() * 1.2;
result = PixelDepthHelper.FromFloatImage<TDepth>(f);
using (var converted = result.Convert<Gray, float>())
using (var f = converted * 1.2)
{
result.Dispose();
result = PixelDepthHelper.FromFloatImage<TDepth>(f);
}
}
else if (method == "Min")
{
var f = result.Convert<Gray, float>() * 0.8;
result = PixelDepthHelper.FromFloatImage<TDepth>(f);
using (var converted = result.Convert<Gray, float>())
using (var f = converted * 0.8)
{
result.Dispose();
result = PixelDepthHelper.FromFloatImage<TDepth>(f);
}
}
_logger.Debug("Process: Method = {Method}", method);
@@ -100,8 +100,10 @@ public class SubPixelZoomProcessor<TDepth> : ImageProcessorBase<TDepth>
if (sharpenAfter)
{
int ksize = Math.Max(3, (int)(scaleFactor * 2) | 1);
var resultF = result.Convert<Gray, float>();
var blurredF = new Image<Gray, float>(newWidth, newHeight);
CvInvoke.GaussianBlur(result, blurredF, new Size(ksize, ksize), 0);
CvInvoke.GaussianBlur(resultF, blurredF, new Size(ksize, ksize), 0);
resultF.Dispose();
for (int y = 0; y < newHeight; y++)
for (int x = 0; x < newWidth; x++)
@@ -71,6 +71,9 @@ public class DifferenceProcessor<TDepth> : ImageProcessorBase<TDepth>
for (int x = 0; x < width - 1; x++)
result.Data[y, x, 0] = PixelDepthHelper.ReadPixel(inputImage, y, x + 1)
- PixelDepthHelper.ReadPixel(inputImage, y, x);
// 填充最后一列:复制次末列的值
for (int y = 0; y < height; y++)
result.Data[y, width - 1, 0] = result.Data[y, width - 2, 0];
}
else if (direction == "Vertical")
{
@@ -78,6 +81,9 @@ public class DifferenceProcessor<TDepth> : ImageProcessorBase<TDepth>
for (int x = 0; x < width; x++)
result.Data[y, x, 0] = PixelDepthHelper.ReadPixel(inputImage, y + 1, x)
- PixelDepthHelper.ReadPixel(inputImage, y, x);
// 填充最后一行:复制次末行的值
for (int x = 0; x < width; x++)
result.Data[height - 1, x, 0] = result.Data[height - 2, x, 0];
}
else
{
@@ -90,6 +96,12 @@ public class DifferenceProcessor<TDepth> : ImageProcessorBase<TDepth>
- PixelDepthHelper.ReadPixel(inputImage, y, x);
result.Data[y, x, 0] = (float)Math.Sqrt(dx * dx + dy * dy);
}
// 填充右边界列(最右列,不含右下角)
for (int y = 0; y < height - 1; y++)
result.Data[y, width - 1, 0] = result.Data[y, width - 2, 0];
// 填充底部行(含右下角)
for (int x = 0; x < width; x++)
result.Data[height - 1, x, 0] = result.Data[height - 2, x, 0];
}
_logger.Debug("Process: Direction = {Direction}, Normalize = {Normalize}", direction, normalize);
@@ -46,7 +46,9 @@ public enum VoidDetectionMode
/// <summary>白帽变换(Top-hat)检测相对偏亮气泡,抗厚度梯度</summary>
TopHat,
/// <summary>局部相对对比度——高斯模糊作背景,(像素−背景)/背景×100% ≥ 阈值即气泡。根治厚度梯度</summary>
LocalContrast
LocalContrast,
/// <summary>自适应统计阈值——每焊球独立统计 median+MAD,单参数灵敏度控制。抗亮度差异、抗厚度梯度</summary>
AdaptiveStatistics
}
public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
@@ -143,8 +145,13 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
Parameters.Add("VoidDetectionMode", new ProcessorParameter(
"VoidDetectionMode",
"气泡检测模式", typeof(string), "Fixed", null, null,
"气泡分割算法: Fixed=固定阈值, TopHat=白帽变换, LocalContrast=局部对比度抗厚度梯度",
new string[] { "Fixed", "TopHat", "LocalContrast" }));
"气泡分割算法: Fixed=固定阈值, TopHat=白帽变换, LocalContrast=局部对比度抗厚度梯度, AdaptiveStatistics=自适应统计阈值",
new string[] { "Fixed", "TopHat", "LocalContrast", "AdaptiveStatistics" }));
Parameters.Add("VoidSensitivity", new ProcessorParameter(
"VoidSensitivity",
"气泡灵敏度", typeof(double), 2.5, 0.5, 5.0,
"自适应统计阈值灵敏度:值越小检测越保守(只检出高亮气泡),值越大越灵敏(检出更多)。阈值 = median + sensitivity × MAD。仅 AdaptiveStatistics 时生效"));
Parameters.Add("TopHatKernelSize", new ProcessorParameter(
"TopHatKernelSize",
@@ -190,6 +197,12 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
typeof(double), 25.0, 0.0, 100.0,
LocalizationHelper.GetString("BgaVoidRateProcessor_VoidLimit_Desc")));
Parameters.Add("MaxSingleVoidLimit", new ProcessorParameter(
"MaxSingleVoidLimit",
"最大单气泡限值(%)",
typeof(double), 10.0, 0.0, 100.0,
"单个焊球内最大气泡面积占比上限。超过此值的焊球直接判为 FAIL,与总空隙率无关。0=不限制"));
Parameters.Add("Thickness", new ProcessorParameter(
"Thickness",
LocalizationHelper.GetString("BgaVoidRateProcessor_Thickness"),
@@ -200,32 +213,6 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
Parameters.Add("ShowResultDialog", new ProcessorParameter(
"ShowResultDialog", "运行时弹出结果", typeof(bool), true, null, null,
"CNC 运行到本模块时弹出结果复核窗,供操作员查看明细并判定"));
// ── 输出控制项:控制 CNC 执行时产出哪些结果字段写入归档 ──
Parameters.Add("OutputClassification", new ProcessorParameter(
"OutputClassification", "输出判定", typeof(bool), true, null, null,
"是否输出焊球判定结果(PASS/FAIL"));
Parameters.Add("OutputCenterX", new ProcessorParameter(
"OutputCenterX", "输出中心X", typeof(bool), true, null, null,
"是否输出焊球中心X坐标"));
Parameters.Add("OutputCenterY", new ProcessorParameter(
"OutputCenterY", "输出中心Y", typeof(bool), true, null, null,
"是否输出焊球中心Y坐标"));
Parameters.Add("OutputBgaArea", new ProcessorParameter(
"OutputBgaArea", "输出焊球面积", typeof(bool), true, null, null,
"是否输出焊球面积(像素)"));
Parameters.Add("OutputVoidRate", new ProcessorParameter(
"OutputVoidRate", "输出空隙率", typeof(bool), true, null, null,
"是否输出焊球空隙率(%)"));
Parameters.Add("OutputMaxVoidRate", new ProcessorParameter(
"OutputMaxVoidRate", "输出最大气泡", typeof(bool), true, null, null,
"是否输出最大单个气泡占比(%)"));
Parameters.Add("OutputVoidCount", new ProcessorParameter(
"OutputVoidCount", "输出气泡数", typeof(bool), true, null, null,
"是否输出焊球内气泡数量"));
Parameters.Add("OutputCircularity", new ProcessorParameter(
"OutputCircularity", "输出圆度", typeof(bool), true, null, null,
"是否输出焊球圆度"));
}
public override Image<Gray, ushort> Process(Image<Gray, ushort> inputImage)
@@ -244,6 +231,7 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
double bgaProtrusionRatio = GetParameter<double>("BgaProtrusionRatio");
double bgaEllipseClipScale = GetParameter<double>("BgaEllipseClipScale");
string voidMode = GetParameter<string>("VoidDetectionMode");
double voidSensitivity = GetParameter<double>("VoidSensitivity");
int topHatKernelSize = GetParameter<int>("TopHatKernelSize");
int localContrastWindowRadius = GetParameter<int>("LocalContrastWindowRadius");
double localContrastThreshold = GetParameter<double>("LocalContrastThreshold");
@@ -252,9 +240,16 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
int maxThresh = GetParameter<int>("MaxThreshold");
int minVoidArea = GetParameter<int>("MinVoidArea");
double voidLimit = GetParameter<double>("VoidLimit");
double maxSingleVoidLimit = GetParameter<double>("MaxSingleVoidLimit");
int thickness = GetParameter<int>("Thickness");
if (bgaBlurSize % 2 == 0) bgaBlurSize++;
if (bgaThreshLow > bgaThreshHigh)
{
_logger.Warning("BgaThresholdLow({Low}) > BgaThresholdHigh({High}),已自动交换避免零检测",
bgaThreshLow, bgaThreshHigh);
(bgaThreshLow, bgaThreshHigh) = (bgaThreshHigh, bgaThreshLow);
}
OutputData.Clear();
int w = inputImage.Width, h = inputImage.Height;
@@ -299,6 +294,7 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
OutputData["ResultText"] = "No BGA detected";
OutputData["Thickness"] = thickness;
OutputData["VoidLimit"] = voidLimit;
OutputData["MaxSingleVoidLimit"] = maxSingleVoidLimit;
OutputData["TotalBgaArea"] = 0;
OutputData["TotalVoidArea"] = 0;
OutputData["TotalVoidCount"] = 0;
@@ -316,7 +312,7 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
foreach (var bga in bgaResults)
{
DetectVoidsInBga(inputImage, bga, minThresh, maxThresh, minVoidArea,
voidMode, topHatKernelSize, localContrastWindowRadius, localContrastThreshold,
voidMode, voidSensitivity, topHatKernelSize, localContrastWindowRadius, localContrastThreshold,
localContrastAbsMin);
totalBgaArea += bga.BgaArea;
totalVoidArea += bga.VoidPixels;
@@ -327,7 +323,10 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
string classification = overallVoidRate <= voidLimit ? "PASS" : "FAIL";
foreach (var bga in bgaResults)
bga.Classification = bga.VoidRate <= voidLimit ? "PASS" : "FAIL";
{
double maxSingle = bga.Voids.Count > 0 ? bga.Voids.Max(v => v.AreaPercent) : 0;
bga.Classification = (bga.VoidRate <= voidLimit && maxSingle <= maxSingleVoidLimit) ? "PASS" : "FAIL";
}
_logger.Information("第二步完成: 总气泡率={VoidRate:F1}%, 气泡数={Count}, 判定={Class}",
overallVoidRate, totalVoidCount, classification);
@@ -341,6 +340,7 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
OutputData["TotalVoidArea"] = totalVoidArea;
OutputData["TotalVoidCount"] = totalVoidCount;
OutputData["VoidLimit"] = voidLimit;
OutputData["MaxSingleVoidLimit"] = maxSingleVoidLimit;
OutputData["Classification"] = classification;
OutputData["Thickness"] = thickness;
OutputData["ResultText"] = $"Void: {overallVoidRate:F1}% | {classification} | BGA×{bgaResults.Count}";
@@ -680,11 +680,11 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
}
/// <summary>
/// 第二步:在单个焊球区域内检测气泡(支持 Fixed / Percentile / Otsu / TopHat 四种模式)
/// 第二步:在单个焊球区域内检测气泡(支持 Fixed / TopHat / LocalContrast / AdaptiveStatistics 四种模式)
/// </summary>
private void DetectVoidsInBga(Image<Gray, ushort> input, BgaBallInfo bga,
int minThresh, int maxThresh, int minVoidArea,
string voidMode, int topHatKernelSize,
string voidMode, double voidSensitivity, int topHatKernelSize,
int localContrastWindowRadius, double localContrastThreshold,
int localContrastAbsMin)
{
@@ -716,17 +716,28 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
ApplyLocalContrastThreshold(input, mask, localContrastWindowRadius,
localContrastThreshold, localContrastAbsMin, voidBinary);
break;
case "AdaptiveStatistics":
ApplyAdaptiveStatisticsThreshold(input, mask, voidSensitivity, voidBinary);
break;
default:
ApplyFixedThreshold(input, mask, minThresh, maxThresh, voidBinary);
break;
}
// ── 阶段2统一形态学闭运算,补全气泡断裂边缘 ──
// ── 阶段2:形态学闭运算7×7,连通气泡断裂 ──
using var closeKernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(5, 5), new Point(-1, -1));
new Size(7, 7), new Point(-1, -1));
CvInvoke.MorphologyEx(voidBinary, voidBinary, MorphOp.Close, closeKernel,
new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
// ── 阶段2.5:剔除焊球边缘气泡(内缩5像素,焊球外缘不存在真空泡) ──
var erodedMask = new Image<Gray, byte>(w, h);
using var erodeKernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(3, 3), new Point(-1, -1));
CvInvoke.Erode(mask, erodedMask, erodeKernel, new Point(-1, -1), 5, BorderType.Default, new MCvScalar(0));
CvInvoke.BitwiseAnd(voidBinary, erodedMask, voidBinary);
erodedMask.Dispose();
// ── 阶段3:轮廓检测、形状过滤与信息提取 ──
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
@@ -751,6 +762,11 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
if (minor > 0 && major / minor > 3.0) continue;
}
// 轮廓平滑:epsilon=2.0 去掉像素级锯齿,但保留重叠气泡的真实轮廓
// (不做椭圆拟合,避免两个重叠气泡被错误合并为一个椭圆)
using var smoothed = new VectorOfPoint();
CvInvoke.ApproxPolyDP(contours[i], smoothed, 2.0, true);
filteredVoidArea += (int)Math.Round(area);
bga.Voids.Add(new VoidInfo
{
@@ -760,7 +776,7 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
Area = area,
AreaPercent = bgaPixels > 0 ? area / bgaPixels * 100.0 : 0,
BoundingBox = CvInvoke.BoundingRectangle(contours[i]),
ContourPoints = contours[i].ToArray()
ContourPoints = smoothed.ToArray()
});
}
@@ -865,6 +881,73 @@ public class BgaVoidRateProcessor : ImageProcessorBase<ushort>
blurred.Dispose();
}
/// <summary>
/// 自适应统计阈值——只对焊球内**下半个分布**统计 median+MAD
/// 排除气泡像素对统计量的污染。single-pass、自动适配球间亮度差异。
/// </summary>
/// <param name="sensitivity">0.5(保守)5.0(灵敏),默认2.5</param>
private static void ApplyAdaptiveStatisticsThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
double sensitivity, Image<Gray, byte> output)
{
int w = input.Width, h = input.Height;
var src = input.Data;
var msk = mask.Data;
var dst = output.Data;
// Step 1: 收集焊球区域内所有像素值
var pixelValues = new List<int>();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0)
pixelValues.Add(src[y, x, 0]);
if (pixelValues.Count < 4) return; // 像素太少不统计
// Step 2: 排序,只取下半个分布(下半一定是纯焊锡主体,不受气泡污染)
var sorted = pixelValues.ToArray();
Array.Sort(sorted);
int lowerLen = Math.Max(1, sorted.Length / 2);
int lowerMid = lowerLen / 2;
// 下半部分中位数 (纯焊锡亮度参考)
double medianLow = (lowerLen % 2 == 1)
? sorted[lowerMid]
: (sorted[lowerMid - 1] + sorted[lowerMid]) / 2.0;
// 下半部分 MAD (纯焊锡的离散度,不受气泡影响)
var devLow = new double[lowerLen];
for (int i = 0; i < lowerLen; i++)
devLow[i] = Math.Abs(sorted[i] - medianLow);
Array.Sort(devLow);
double madLow = (lowerLen % 2 == 1)
? devLow[lowerMid]
: (devLow[lowerMid - 1] + devLow[lowerMid]) / 2.0;
// Step 3: 灵敏度 → k 映射 (sens越高→k越小→阈值越低→检出越多)
// k = 5.0 - sensitivity
// sens=0.5→k=4.5(极保守), sens=2.5→k=2.5(默认), sens=5.0→k=0(最灵敏)
double k = 5.0 - sensitivity;
if (k < 0) k = 0;
double threshold = medianLow + k * madLow;
if (threshold > 65535.0) threshold = 65535.0;
if (threshold < 0.0) threshold = 0.0;
ushort threshUshort = (ushort)Math.Round(threshold);
// 诊断日志:每球统计信息
_logger.Information(
"AdaptiveStatistics: ball pixels={Total}, lower-half median={Med:F1}, MAD={Mad:F1}, k={K:F2}, threshold={Thresh}",
sorted.Length, medianLow, madLow, k, threshUshort);
// Step 4: 阈值分割,像素 ≥ threshold 视为气泡候选
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0)
dst[y, x, 0] = src[y, x, 0] >= threshUshort ? (byte)255 : (byte)0;
}
}
/// <summary>
@@ -21,6 +21,8 @@ using Emgu.CV.Util;
using XP.ImageProcessing.Core;
using Serilog;
using System.Drawing;
using System;
using System.Collections.Generic;
namespace XP.ImageProcessing.Processors;
@@ -62,13 +64,13 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
Parameters.Add("PadThresholdLow", new ProcessorParameter(
"PadThresholdLow",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow"),
typeof(int), 0, 0, 255,
typeof(int), 0, 0, 65535,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow_Desc")));
Parameters.Add("PadThresholdHigh", new ProcessorParameter(
"PadThresholdHigh",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh"),
typeof(int), 120, 0, 255,
typeof(int), 120, 0, 65535,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh_Desc")));
Parameters.Add("PadMorphKernel", new ProcessorParameter(
@@ -99,15 +101,47 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
Parameters.Add("VoidThresholdLow", new ProcessorParameter(
"VoidThresholdLow",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow"),
typeof(int), 128, 0, 255,
typeof(int), 32768, 0, 65535,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow_Desc")));
Parameters.Add("VoidThresholdHigh", new ProcessorParameter(
"VoidThresholdHigh",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh"),
typeof(int), 255, 0, 255,
typeof(int), 65535, 0, 65535,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc")));
// ── 空洞检测模式选择 ──
Parameters.Add("VoidDetectionMode", new ProcessorParameter(
"VoidDetectionMode",
"空洞检测模式", typeof(string), "Fixed", null, null,
"空洞分割算法: Fixed=固定双阈值, TopHat=白帽变换, LocalContrast=局部对比度, AdaptiveStatistics=自适应统计阈值",
new string[] { "Fixed", "TopHat", "LocalContrast", "AdaptiveStatistics" }));
Parameters.Add("VoidSensitivity", new ProcessorParameter(
"VoidSensitivity",
"灵敏度(AdaptiveStatistics)", typeof(double), 2.5, 0.5, 5.0,
"自适应统计阈值灵敏度:值越小越保守,值越大越灵敏。仅 AdaptiveStatistics 时生效"));
Parameters.Add("TopHatKernelSize", new ProcessorParameter(
"TopHatKernelSize",
"TopHat核尺寸", typeof(int), 15, 3, 31,
"白帽变换结构元尺寸,仅 TopHat 模式生效"));
Parameters.Add("LocalContrastWindowRadius", new ProcessorParameter(
"LocalContrastWindowRadius",
"局部窗口半径(LocalContrast)", typeof(int), 20, 5, 200,
"局部对比度高斯模糊窗口半径(像素),仅 LocalContrast 生效"));
Parameters.Add("LocalContrastThreshold", new ProcessorParameter(
"LocalContrastThreshold",
"局部对比度阈值(%)", typeof(double), 8.0, 2.0, 50.0,
"(像素-背景)/背景×100% ≥ 阈值即空洞,仅 LocalContrast 生效"));
Parameters.Add("LocalContrastAbsMin", new ProcessorParameter(
"LocalContrastAbsMin",
"局部对比度绝对下限", typeof(int), 0, 0, 65535,
"像素与背景的绝对差值下限(灰度值),默认0=不限制。仅 LocalContrast 生效"));
Parameters.Add("MinVoidArea", new ProcessorParameter(
"MinVoidArea",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea"),
@@ -142,26 +176,6 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
Parameters.Add("ShowResultDialog", new ProcessorParameter(
"ShowResultDialog", "运行时弹出结果", typeof(bool), true, null, null,
"CNC 运行到本模块时弹出结果复核窗,供操作员查看明细并判定"));
// ── 输出控制项:控制 CNC 执行时产出哪些结果字段写入归档 ──
Parameters.Add("OutputCenterX", new ProcessorParameter(
"OutputCenterX", "输出中心X", typeof(bool), true, null, null,
"是否输出引脚中心X坐标"));
Parameters.Add("OutputCenterY", new ProcessorParameter(
"OutputCenterY", "输出中心Y", typeof(bool), true, null, null,
"是否输出引脚中心Y坐标"));
Parameters.Add("OutputPadArea", new ProcessorParameter(
"OutputPadArea", "输出面积", typeof(bool), true, null, null,
"是否输出引脚面积(像素)"));
Parameters.Add("OutputVoidRate", new ProcessorParameter(
"OutputVoidRate", "输出空洞率", typeof(bool), true, null, null,
"是否输出引脚空洞率(%)"));
Parameters.Add("OutputVoidCount", new ProcessorParameter(
"OutputVoidCount", "输出空洞数", typeof(bool), true, null, null,
"是否输出引脚空洞数量"));
Parameters.Add("OutputClassification", new ProcessorParameter(
"OutputClassification", "输出判定", typeof(bool), true, null, null,
"是否输出引脚判定结果(PASS/FAIL"));
}
public override Image<Gray, ushort> Process(Image<Gray, ushort> inputImage)
@@ -182,6 +196,12 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
double voidRateLimit = GetParameter<double>("VoidRateLimit");
int minQualifiedPadArea = GetParameter<int>("MinQualifiedPadArea");
int thickness = GetParameter<int>("Thickness");
string voidMode = GetParameter<string>("VoidDetectionMode");
double voidSensitivity = GetParameter<double>("VoidSensitivity");
int topHatSize = GetParameter<int>("TopHatKernelSize");
int lcRadius = GetParameter<int>("LocalContrastWindowRadius");
double lcThreshold = GetParameter<double>("LocalContrastThreshold");
int lcAbsMin = GetParameter<int>("LocalContrastAbsMin");
// 确保模糊核为奇数
if (padBlurSize % 2 == 0) padBlurSize++;
@@ -249,7 +269,8 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
foreach (var pad in leadPads)
{
DetectVoidsInLeadPad(inputImage, pad, voidThreshLow, voidThreshHigh, minVoidArea, voidMergeRadius);
DetectVoidsInLeadPad(inputImage, pad, voidThreshLow, voidThreshHigh, minVoidArea, voidMergeRadius,
voidMode, voidSensitivity, topHatSize, lcRadius, lcThreshold, lcAbsMin);
totalPadArea += pad.PadArea;
totalVoidArea += pad.VoidPixels;
totalVoidCount += pad.Voids.Count;
@@ -412,12 +433,13 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
}
/// <summary>
/// 第二步:在单个引脚焊点区域内检测空洞
/// 使用引脚轮廓作为掩码,双阈值分割空洞区域
/// 第二步:在单个引脚焊点区域内检测空洞(支持 Fixed/TopHat/LocalContrast/AdaptiveStatistics
/// </summary>
private void DetectVoidsInLeadPad(
Image<Gray, ushort> input, QfnLeadPadInfo pad,
int voidThreshLow, int voidThreshHigh, int minVoidArea, int mergeRadius)
int voidThreshLow, int voidThreshHigh, int minVoidArea, int mergeRadius,
string voidMode, double voidSensitivity, int topHatSize,
int lcRadius, double lcThreshold, int lcAbsMin)
{
int w = input.Width, h = input.Height;
@@ -432,34 +454,52 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
int padPixels = CvInvoke.CountNonZero(mask);
pad.PadArea = padPixels;
// 在 16 位图上做双阈值分割,输出 8 位二值图
// ── 空洞二值图(8位) ──
var voidImg = new Image<Gray, byte>(w, h);
var srcData = input.Data;
var dstData = voidImg.Data;
var maskData = mask.Data;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (maskData[y, x, 0] > 0)
{
ushort val = srcData[y, x, 0];
dstData[y, x, 0] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0;
}
}
// ── 阶段1:按模式提取空洞像素 ──
switch (voidMode)
{
case "Fixed":
ApplyFixedThreshold(input, mask, voidThreshLow, voidThreshHigh, voidImg);
break;
case "TopHat":
int padMedian = ComputeMedian(input, mask);
if (padMedian <= 0) padMedian = 32768;
double scale = Math.Max(1.0, padMedian / 5.0);
int scaledMin = (int)Math.Round(voidThreshLow / scale);
ApplyTopHatThreshold(input, mask, topHatSize, scaledMin, 65535, voidImg);
break;
case "LocalContrast":
ApplyLocalContrastThreshold(input, mask, lcRadius, lcThreshold, lcAbsMin, voidImg);
break;
case "AdaptiveStatistics":
ApplyAdaptiveStatisticsThreshold(input, mask, voidSensitivity, voidImg);
break;
default:
ApplyFixedThreshold(input, mask, voidThreshLow, voidThreshHigh, voidImg);
break;
}
// 形态学膨胀合并相邻空洞
// ── 阶段2:形态学闭运算连通空洞碎片 + 引脚边缘剔除 ──
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);
CvInvoke.MorphologyEx(voidImg, voidImg, MorphOp.Close, kernel,
new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
}
// 检测每个空洞的轮廓
// 引脚边缘剔除:焊盘边缘往往是亮度过渡带,内缩5像素排除假阳性
var erodedMask = new Image<Gray, byte>(w, h);
using var erodeKernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(3, 3), new Point(-1, -1));
CvInvoke.Erode(mask, erodedMask, erodeKernel, new Point(-1, -1), 5, BorderType.Default, new MCvScalar(0));
CvInvoke.BitwiseAnd(voidImg, erodedMask, voidImg);
erodedMask.Dispose();
// ── 阶段3:轮廓检测、形状过滤与信息提取 ──
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
@@ -473,6 +513,19 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
var moments = CvInvoke.Moments(contours[i]);
if (moments.M00 < 1) continue;
// 形状过滤:只保留近似圆形/椭圆的空洞
if (contours[i].Size >= 5)
{
var ellipse = CvInvoke.FitEllipse(contours[i]);
double major = Math.Max(ellipse.Size.Width, ellipse.Size.Height);
double minor = Math.Min(ellipse.Size.Width, ellipse.Size.Height);
if (minor > 0 && major / minor > 3.0) continue;
}
// 轮廓平滑
using var smoothed = new VectorOfPoint();
CvInvoke.ApproxPolyDP(contours[i], smoothed, 2.0, true);
filteredVoidArea += (int)Math.Round(area);
pad.Voids.Add(new QfnLeadVoidInfo
{
@@ -482,7 +535,7 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
Area = area,
AreaPercent = padPixels > 0 ? area / padPixels * 100.0 : 0,
BoundingBox = CvInvoke.BoundingRectangle(contours[i]),
ContourPoints = contours[i].ToArray()
ContourPoints = smoothed.ToArray()
});
}
@@ -497,6 +550,97 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase<ushort>
mask.Dispose();
voidImg.Dispose();
}
#region
private static int ComputeMedian(Image<Gray, ushort> image, Image<Gray, byte> mask)
{
var vals = new List<int>();
var src = image.Data;
var msk = mask.Data;
for (int y = 0; y < image.Height; y++)
for (int x = 0; x < image.Width; x++)
if (msk[y, x, 0] > 0) vals.Add(src[y, x, 0]);
if (vals.Count == 0) return 0;
var s = vals.ToArray(); Array.Sort(s);
int mid = s.Length / 2;
return s.Length % 2 == 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
private static void ApplyFixedThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int minThresh, int maxThresh, Image<Gray, byte> output)
{
var src = input.Data; var msk = mask.Data; var dst = output.Data;
for (int y = 0; y < input.Height; y++)
for (int x = 0; x < input.Width; x++)
if (msk[y, x, 0] > 0)
{
ushort val = src[y, x, 0];
dst[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0;
}
}
private static void ApplyTopHatThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int kernelSize, int minThresh, int maxThresh, Image<Gray, byte> output)
{
int ks = (kernelSize % 2 == 0) ? kernelSize + 1 : kernelSize;
using var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse, new Size(ks, ks), new Point(-1, -1));
using var opened = new Image<Gray, ushort>(input.Width, input.Height);
CvInvoke.MorphologyEx(input, opened, MorphOp.Open, kernel, new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
var topHat = input - opened;
ApplyFixedThreshold(topHat, mask, minThresh, maxThresh, output);
topHat.Dispose();
}
private static void ApplyLocalContrastThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int windowRadius, double thresholdPercent, int absMin, Image<Gray, byte> output)
{
int w = input.Width, h = input.Height;
double sigma = windowRadius / 2.0;
var blurred = new Image<Gray, ushort>(w, h);
CvInvoke.GaussianBlur(input, blurred, new Size(0, 0), sigma, sigma);
var src = input.Data; var blr = blurred.Data; var msk = mask.Data; var dst = output.Data;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (msk[y, x, 0] == 0) continue;
ushort srcVal = src[y, x, 0];
if (srcVal < absMin) continue;
ushort bgVal = blr[y, x, 0];
if (bgVal == 0) continue;
double contrast = (srcVal - bgVal) * 100.0 / bgVal;
if (contrast >= thresholdPercent) dst[y, x, 0] = 255;
}
blurred.Dispose();
}
private static void ApplyAdaptiveStatisticsThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
double sensitivity, Image<Gray, byte> output)
{
int w = input.Width, h = input.Height;
var src = input.Data; var msk = mask.Data; var dst = output.Data;
var vals = new List<int>();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0) vals.Add(src[y, x, 0]);
if (vals.Count < 4) return;
var s = vals.ToArray(); Array.Sort(s);
int lowLen = Math.Max(1, s.Length / 2);
int lowMid = lowLen / 2;
double med = lowLen % 2 == 1 ? s[lowMid] : (s[lowMid - 1] + s[lowMid]) / 2.0;
var dev = new double[lowLen];
for (int i = 0; i < lowLen; i++) dev[i] = Math.Abs(s[i] - med);
Array.Sort(dev);
double mad = lowLen % 2 == 1 ? dev[lowMid] : (dev[lowMid - 1] + dev[lowMid]) / 2.0;
double k = 5.0 - sensitivity; if (k < 0) k = 0;
double t = med + k * mad; if (t > 65535) t = 65535;
ushort th = (ushort)Math.Round(t);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0) dst[y, x, 0] = src[y, x, 0] >= th ? (byte)255 : (byte)0;
}
#endregion
}
/// <summary>
@@ -17,6 +17,8 @@ using Emgu.CV.Util;
using XP.ImageProcessing.Core;
using Serilog;
using System.Drawing;
using System;
using System.Collections.Generic;
namespace XP.ImageProcessing.Processors;
@@ -87,19 +89,37 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
typeof(double), 25.0, 0.0, 100.0,
LocalizationHelper.GetString("VoidMeasurementProcessor_VoidLimit_Desc")));
// ── 输出控制项:控制 CNC 执行时产出哪些结果字段写入归档 ──
Parameters.Add("OutputCenterX", new ProcessorParameter(
"OutputCenterX", "输出中心X", typeof(bool), true, null, null,
"是否输出空隙中心X坐标"));
Parameters.Add("OutputCenterY", new ProcessorParameter(
"OutputCenterY", "输出中心Y", typeof(bool), true, null, null,
"是否输出空隙中心Y坐标"));
Parameters.Add("OutputArea", new ProcessorParameter(
"OutputArea", "输出面积", typeof(bool), true, null, null,
"是否输出空隙面积(像素)"));
Parameters.Add("OutputAreaPercent", new ProcessorParameter(
"OutputAreaPercent", "输出占比", typeof(bool), true, null, null,
"是否输出空隙面积占比(%)"));
// ── 气泡检测模式选择 ──
Parameters.Add("VoidDetectionMode", new ProcessorParameter(
"VoidDetectionMode",
"空隙检测模式", typeof(string), "Fixed", null, null,
"空隙分割算法: Fixed=固定双阈值, TopHat=白帽变换, LocalContrast=局部对比度抗厚度梯度, AdaptiveStatistics=自适应统计阈值",
new string[] { "Fixed", "TopHat", "LocalContrast", "AdaptiveStatistics" }));
Parameters.Add("VoidSensitivity", new ProcessorParameter(
"VoidSensitivity",
"灵敏度(AdaptiveStatistics)", typeof(double), 2.5, 0.5, 5.0,
"自适应统计阈值灵敏度:值越小越保守(只检出高亮空隙),值越大越灵敏。仅 AdaptiveStatistics 时生效"));
Parameters.Add("TopHatKernelSize", new ProcessorParameter(
"TopHatKernelSize",
"TopHat核尺寸", typeof(int), 15, 3, 31,
"白帽变换结构元尺寸,仅 TopHat 模式生效"));
Parameters.Add("LocalContrastWindowRadius", new ProcessorParameter(
"LocalContrastWindowRadius",
"局部窗口半径(LocalContrast)", typeof(int), 20, 5, 500,
"局部对比度高斯模糊窗口半径(像素),仅 LocalContrast 生效。调小→检测更小空隙"));
Parameters.Add("LocalContrastThreshold", new ProcessorParameter(
"LocalContrastThreshold",
"局部对比度阈值(%)", typeof(double), 8.0, 2.0, 90.0,
"(像素-背景)/背景×100% ≥ 阈值即空隙,仅 LocalContrast 生效。调小→更灵敏"));
Parameters.Add("LocalContrastAbsMin", new ProcessorParameter(
"LocalContrastAbsMin",
"局部对比度绝对下限", typeof(int), 0, 0, 65535,
"像素与背景的绝对差值下限(灰度值),仅 LocalContrast 生效。默认0=不限制,调大→抑制噪声但可能漏检小空隙"));
}
public override Image<Gray, ushort> Process(Image<Gray, ushort> inputImage)
@@ -110,8 +130,19 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
int mergeRadius = GetParameter<int>("MergeRadius");
int blurSize = GetParameter<int>("BlurSize");
double voidLimit = GetParameter<double>("VoidLimit");
string voidMode = GetParameter<string>("VoidDetectionMode");
double voidSensitivity = GetParameter<double>("VoidSensitivity");
int topHatSize = GetParameter<int>("TopHatKernelSize");
int lcRadius = GetParameter<int>("LocalContrastWindowRadius");
double lcThreshold = GetParameter<double>("LocalContrastThreshold");
int lcAbsMin = GetParameter<int>("LocalContrastAbsMin");
if (blurSize % 2 == 0) blurSize++;
if (minThresh > maxThresh)
{
_logger.Warning("MinThreshold({Min}) > MaxThreshold({Max}),已自动交换", minThresh, maxThresh);
(minThresh, maxThresh) = (maxThresh, minThresh);
}
OutputData.Clear();
int w = inputImage.Width, h = inputImage.Height;
@@ -165,40 +196,55 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
roiArea = newRoiArea;
}
_logger.Debug("VoidMeasurement(16bit): ROI area={Area}, ExcludedArea={Excluded}, Thresh=[{Min},{Max}], MergeR={MR}",
roiArea, excludedArea, minThresh, maxThresh, mergeRadius);
_logger.Debug("VoidMeasurement: ROI area={Area}, ExcludedArea={Excluded}, Mode={Mode}",
roiArea, excludedArea, voidMode);
// ── 高斯模糊降噪(CV_16U 支持)──
var blurred = new Image<Gray, ushort>(w, h);
CvInvoke.GaussianBlur(inputImage, blurred, new Size(blurSize, blurSize), 0);
// ── 在 16 位图上做双阈值分割,输出 8 位二值图 ──
// ── 空隙二值图(8位) ──
var voidImg = new Image<Gray, byte>(w, h);
var srcData = blurred.Data;
var dstData = voidImg.Data;
var mskData = roiMask.Data;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (mskData[y, x, 0] > 0)
{
ushort val = srcData[y, x, 0];
dstData[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0;
}
}
// ── 阶段1:按模式提取空隙像素(使用原始图,与BGA一致)──
switch (voidMode)
{
case "Fixed":
ApplyFixedThreshold(inputImage, roiMask, minThresh, maxThresh, voidImg);
break;
case "TopHat":
int imgMedian = ComputeMedian(inputImage, roiMask);
if (imgMedian <= 0) imgMedian = 32768;
double scale = Math.Max(1.0, imgMedian / 5.0);
int scaledMin = (int)Math.Round(minThresh / scale);
ApplyTopHatThreshold(inputImage, roiMask, topHatSize, scaledMin, 65535, voidImg);
break;
case "LocalContrast":
ApplyLocalContrastThreshold(inputImage, roiMask, lcRadius, lcThreshold, lcAbsMin, voidImg);
break;
case "AdaptiveStatistics":
ApplyAdaptiveStatisticsThreshold(inputImage, roiMask, voidSensitivity, voidImg);
break;
default:
ApplyFixedThreshold(inputImage, roiMask, minThresh, maxThresh, voidImg);
break;
}
// ── 形态学膨胀合并相邻气泡 ──
// ── 阶段2:形态学闭运算连通气泡碎片 + 边缘剔除(与BGA一致)──
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, roiMask, voidImg);
CvInvoke.MorphologyEx(voidImg, voidImg, MorphOp.Close, kernel,
new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
}
// ── 轮廓检测 ──
// ROI边缘剔除:手工选区边缘是亮度过渡带,内缩5像素排除假阳性
var erodedMask = new Image<Gray, byte>(w, h);
using var erodeKernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(3, 3), new Point(-1, -1));
CvInvoke.Erode(roiMask, erodedMask, erodeKernel, new Point(-1, -1), 5, BorderType.Default, new MCvScalar(0));
CvInvoke.BitwiseAnd(voidImg, erodedMask, voidImg);
erodedMask.Dispose();
// ── 阶段3:轮廓检测、形状过滤与信息提取 ──
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
@@ -214,6 +260,19 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
var moments = CvInvoke.Moments(contours[i]);
if (moments.M00 < 1) continue;
// 形状过滤:只保留近似圆形/椭圆的空隙,去掉细长裂纹/噪声
if (contours[i].Size >= 5)
{
var ellipse = CvInvoke.FitEllipse(contours[i]);
double major = Math.Max(ellipse.Size.Width, ellipse.Size.Height);
double minor = Math.Min(ellipse.Size.Width, ellipse.Size.Height);
if (minor > 0 && major / minor > 3.0) continue;
}
// 轮廓平滑:ApproxPolyDP 去掉像素锯齿
using var smoothed = new VectorOfPoint();
CvInvoke.ApproxPolyDP(contours[i], smoothed, 2.0, true);
int intArea = (int)Math.Round(area);
totalVoidArea += intArea;
@@ -225,7 +284,7 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
Area = intArea,
AreaPercent = roiArea > 0 ? area / roiArea * 100.0 : 0,
BoundingBox = CvInvoke.BoundingRectangle(contours[i]),
ContourPoints = contours[i].ToArray()
ContourPoints = smoothed.ToArray()
});
}
@@ -236,8 +295,8 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
string classification = voidRate <= voidLimit ? "PASS" : "FAIL";
int maxVoidArea = voids.Count > 0 ? voids[0].Area : 0;
_logger.Information("VoidMeasurement: VoidRate={Rate:F1}%, Voids={Count}, MaxArea={Max}, {Class}",
voidRate, voids.Count, maxVoidArea, classification);
_logger.Information("VoidMeasurement[{Mode}]: VoidRate={Rate:F1}%, Voids={Count}, MaxArea={Max}, {Class}",
voidMode, voidRate, voids.Count, maxVoidArea, classification);
// ── 输出数据 ──
OutputData["VoidMeasurementResult"] = true;
@@ -250,14 +309,140 @@ public class VoidMeasurementProcessor : ImageProcessorBase<ushort>
OutputData["MaxVoidArea"] = maxVoidArea;
OutputData["Classification"] = classification;
OutputData["Voids"] = voids;
OutputData["ResultText"] = $"Void: {voidRate:F1}% | {classification} | {voids.Count} voids | ROI: {roiArea}px";
OutputData["ResultText"] = $"Void[{voidMode}]: {voidRate:F1}% | {classification} | {voids.Count} voids | ROI: {roiArea}px";
blurred.Dispose();
voidImg.Dispose();
roiMask.Dispose();
return inputImage.Clone();
}
#region
/// <summary>计算 ROI 内像素中位数(用于 TopHat 阈值缩放)</summary>
private static int ComputeMedian(Image<Gray, ushort> image, Image<Gray, byte> mask)
{
var pixelValues = new List<int>();
var src = image.Data;
var msk = mask.Data;
for (int y = 0; y < image.Height; y++)
for (int x = 0; x < image.Width; x++)
if (msk[y, x, 0] > 0)
pixelValues.Add(src[y, x, 0]);
if (pixelValues.Count == 0) return 0;
var sorted = pixelValues.ToArray();
Array.Sort(sorted);
int mid = sorted.Length / 2;
return (sorted.Length % 2 == 1) ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
/// <summary>固定双阈值:MinThreshold ≤ pixel ≤ MaxThreshold</summary>
private static void ApplyFixedThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int minThresh, int maxThresh, Image<Gray, byte> output)
{
var src = input.Data;
var msk = mask.Data;
var dst = output.Data;
int h = input.Height, w = input.Width;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0)
{
ushort val = src[y, x, 0];
dst[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0;
}
}
/// <summary>白帽变换:原图 - 开运算,提取比邻域亮的细结构</summary>
private static void ApplyTopHatThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int kernelSize, int minThresh, int maxThresh, Image<Gray, byte> output)
{
int ks = (kernelSize % 2 == 0) ? kernelSize + 1 : kernelSize;
using var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(ks, ks), new Point(-1, -1));
using var opened = new Image<Gray, ushort>(input.Width, input.Height);
CvInvoke.MorphologyEx(input, opened, MorphOp.Open, kernel, new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
// topHat = input - opened
var topHat = input - opened;
ApplyFixedThreshold(topHat, mask, minThresh, maxThresh, output);
topHat.Dispose();
}
/// <summary>局部对比度:(像素−背景)/背景×100% ≥ 阈值即空隙,根治厚度梯度。与 BGA 同款实现</summary>
private static void ApplyLocalContrastThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
int windowRadius, double thresholdPercent, int absMin, Image<Gray, byte> output)
{
int w = input.Width, h = input.Height;
double sigma = windowRadius / 2.0;
var blurred = new Image<Gray, ushort>(w, h);
CvInvoke.GaussianBlur(input, blurred, new Size(0, 0), sigma, sigma);
var src = input.Data;
var blr = blurred.Data;
var msk = mask.Data;
var dst = output.Data;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
if (msk[y, x, 0] == 0) continue;
ushort srcVal = src[y, x, 0];
if (srcVal < absMin) continue;
ushort bgVal = blr[y, x, 0];
if (bgVal == 0) continue;
double contrast = (srcVal - bgVal) * 100.0 / bgVal;
if (contrast >= thresholdPercent)
dst[y, x, 0] = 255;
}
blurred.Dispose();
}
/// <summary>自适应统计阈值:ROI内取下半个分布 median+MAD,单参数灵敏度控制</summary>
private static void ApplyAdaptiveStatisticsThreshold(Image<Gray, ushort> input, Image<Gray, byte> mask,
double sensitivity, Image<Gray, byte> output)
{
int w = input.Width, h = input.Height;
var src = input.Data;
var msk = mask.Data;
var dst = output.Data;
var pixelValues = new List<int>();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0)
pixelValues.Add(src[y, x, 0]);
if (pixelValues.Count < 4) return;
var sorted = pixelValues.ToArray();
Array.Sort(sorted);
int lowerLen = Math.Max(1, sorted.Length / 2);
int lowerMid = lowerLen / 2;
double medianLow = (lowerLen % 2 == 1)
? sorted[lowerMid]
: (sorted[lowerMid - 1] + sorted[lowerMid]) / 2.0;
var devLow = new double[lowerLen];
for (int i = 0; i < lowerLen; i++)
devLow[i] = Math.Abs(sorted[i] - medianLow);
Array.Sort(devLow);
double madLow = (lowerLen % 2 == 1)
? devLow[lowerMid]
: (devLow[lowerMid - 1] + devLow[lowerMid]) / 2.0;
double k = 5.0 - sensitivity;
if (k < 0) k = 0;
double threshold = medianLow + k * madLow;
if (threshold > 65535) threshold = 65535;
ushort thresh = (ushort)Math.Round(threshold);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
if (msk[y, x, 0] > 0)
dst[y, x, 0] = src[y, x, 0] >= thresh ? (byte)255 : (byte)0;
}
#endregion
}
/// <summary>
@@ -70,7 +70,11 @@ public class ShockFilterProcessor<TDepth> : ImageProcessorBase<TDepth>
double dt = GetParameter<double>("Dt");
var result = inputImage.Convert<Gray, float>();
for (int iter = 0; iter < iterations; iter++)
result = ShockFilterIteration(result, theta, dt);
{
var prev = result;
result = ShockFilterIteration(prev, theta, dt);
prev.Dispose();
}
_logger.Debug("Process: Iterations = {Iterations}, Theta = {Theta}, Dt = {Dt}", iterations, theta, dt);
return PixelDepthHelper.FromFloatImage<TDepth>(result);
}
@@ -93,7 +97,7 @@ public class ShockFilterProcessor<TDepth> : ImageProcessorBase<TDepth>
float dyy = input.Data[y + 1, x, 0] - 2 * input.Data[y, x, 0] + input.Data[y - 1, x, 0];
float laplacian = dxx + dyy;
float sign = laplacian > 0 ? 1.0f : -1.0f;
float sign = laplacian > 0 ? 1.0f : (laplacian < 0 ? -1.0f : 0.0f);
if (gradMag > theta)
{
@@ -123,10 +123,14 @@ namespace XplorePlane.ViewModels.ImageProcessing
private double _maxSingleVoidLimit = 10.0;
public double MaxSingleVoidLimit { get => _maxSingleVoidLimit; set => SetProperty(ref _maxSingleVoidLimit, value); }
// 气泡检测模式:Fixed / TopHat / LocalContrast
private string _voidDetectionMode = "LocalContrast";
// 气泡检测模式:Fixed / TopHat / LocalContrast / AdaptiveStatistics
private string _voidDetectionMode = "Fixed";
public string VoidDetectionMode { get => _voidDetectionMode; set => SetProperty(ref _voidDetectionMode, value); }
// 自适应统计灵敏度(仅 AdaptiveStatistics 模式生效)
private double _voidSensitivity = 2.5;
public double VoidSensitivity { get => _voidSensitivity; set => SetProperty(ref _voidSensitivity, value); }
// 白帽变换结构元尺寸(仅 TopHat 模式生效)
private int _topHatKernelSize = 15;
public int TopHatKernelSize { get => _topHatKernelSize; set => SetProperty(ref _topHatKernelSize, value); }
@@ -388,6 +392,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
processor.SetParameter("BgaProtrusionRatio", BgaProtrusionRatio);
processor.SetParameter("BgaEllipseClipScale", BgaEllipseClipScale);
processor.SetParameter("VoidDetectionMode", VoidDetectionMode);
processor.SetParameter("VoidSensitivity", VoidSensitivity);
processor.SetParameter("TopHatKernelSize", TopHatKernelSize);
processor.SetParameter("LocalContrastWindowRadius", LocalContrastWindowRadius);
processor.SetParameter("LocalContrastThreshold", LocalContrastThreshold);
@@ -396,6 +401,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
processor.SetParameter("MaxThreshold", MaxThreshold);
processor.SetParameter("MinVoidArea", MinVoidArea);
processor.SetParameter("VoidLimit", VoidLimit);
processor.SetParameter("MaxSingleVoidLimit", MaxSingleVoidLimit);
processor.SetParameter("RoiMode", "None");
// 如果有 ROI 多边形,注入坐标
@@ -431,14 +437,10 @@ namespace XplorePlane.ViewModels.ImageProcessing
foreach (var bga in sorted)
{
double maxVoid = bga.Voids.Count > 0 ? bga.Voids.Max(v => v.AreaPercent) : 0;
// 额外判定:最大单个气泡占比超限也为NG
string cls = bga.Classification;
if (cls == "PASS" && maxVoid > MaxSingleVoidLimit)
cls = "FAIL";
Results.Add(new BgaResultItem
{
Index = bga.Index,
Classification = cls,
Classification = bga.Classification,
CenterX = bga.CenterX.ToString("F1"),
CenterY = bga.CenterY.ToString("F1"),
BgaArea = bga.BgaArea.ToString(),
@@ -555,6 +557,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
parameters["BgaProtrusionRatio"] = BgaProtrusionRatio;
parameters["BgaEllipseClipScale"] = BgaEllipseClipScale;
parameters["VoidDetectionMode"] = VoidDetectionMode;
parameters["VoidSensitivity"] = VoidSensitivity;
parameters["TopHatKernelSize"] = TopHatKernelSize;
parameters["LocalContrastWindowRadius"] = LocalContrastWindowRadius;
parameters["LocalContrastThreshold"] = LocalContrastThreshold;
@@ -563,6 +566,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
parameters["MaxThreshold"] = MaxThreshold;
parameters["MinVoidArea"] = MinVoidArea;
parameters["VoidLimit"] = VoidLimit;
parameters["MaxSingleVoidLimit"] = MaxSingleVoidLimit;
// 写入 ROI 参数
if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3)
@@ -625,44 +629,34 @@ namespace XplorePlane.ViewModels.ImageProcessing
// 使用统一排序
var sorted = SortBgaBalls(bgaBalls);
// 半透明气泡填充
var overlay = colorImage.Clone();
// 绘制焊球轮廓 + 编号 + 气泡轮廓
foreach (var bga in sorted)
{
var fillColor = new MCvScalar(0, 200, 255);
var ballColor = bga.Classification == "PASS"
? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255);
var voidColor = bga.Classification == "PASS"
? new MCvScalar(132, 255, 87) : new MCvScalar(87, 87, 255);
// 焊球轮廓
if (bga.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(bga.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(colorImage, vvop, 0, ballColor, thickness);
}
// 气泡轮廓(仅轮廓线,不填充)
foreach (var v in bga.Voids)
{
if (v.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(v.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(overlay, vvop, 0, fillColor, -1);
CvInvoke.DrawContours(colorImage, vvop, 0, voidColor, 1);
}
}
}
CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage);
overlay.Dispose();
// 绘制焊球轮廓 + 编号(蓝色,焊球下方
foreach (var bga in sorted)
{
// 应用最大单气泡限值判定
double maxVoid = bga.Voids.Count > 0 ? bga.Voids.Max(v => v.AreaPercent) : 0;
string cls = bga.Classification;
if (cls == "PASS" && maxVoid > MaxSingleVoidLimit)
cls = "FAIL";
var bgaColor = cls == "PASS"
? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255);
if (bga.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(bga.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(colorImage, vvop, 0, bgaColor, thickness);
}
// 编号标注在焊球下方,蓝色字体
// 编号标注在焊球下方
using var bboxVop = new VectorOfPoint(bga.ContourPoints);
var bbox = CvInvoke.BoundingRectangle(bboxVop);
CvInvoke.PutText(colorImage, $"#{bga.Index}",
@@ -671,11 +665,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
}
// 左上角总览结果
int ngCount = sorted.Count(b =>
{
double mv = b.Voids.Count > 0 ? b.Voids.Max(v => v.AreaPercent) : 0;
return b.Classification == "FAIL" || mv > MaxSingleVoidLimit;
});
int ngCount = sorted.Count(b => b.Classification == "FAIL");
int okCount = sorted.Count - ngCount;
var overallColor = ngCount > 0
? new MCvScalar(0, 0, 255) : new MCvScalar(0, 255, 0);
@@ -104,6 +104,25 @@ namespace XplorePlane.ViewModels.ImageProcessing
private double _voidRateLimit = 50.0;
public double VoidRateLimit { get => _voidRateLimit; set => SetProperty(ref _voidRateLimit, value); }
// 空洞检测模式:Fixed / TopHat / LocalContrast / AdaptiveStatistics
private string _voidDetectionMode = "Fixed";
public string VoidDetectionMode { get => _voidDetectionMode; set => SetProperty(ref _voidDetectionMode, value); }
private double _voidSensitivity = 2.5;
public double VoidSensitivity { get => _voidSensitivity; set => SetProperty(ref _voidSensitivity, value); }
private int _topHatKernelSize = 15;
public int TopHatKernelSize { get => _topHatKernelSize; set => SetProperty(ref _topHatKernelSize, value); }
private int _localContrastWindowRadius = 20;
public int LocalContrastWindowRadius { get => _localContrastWindowRadius; set => SetProperty(ref _localContrastWindowRadius, value); }
private double _localContrastThreshold = 8.0;
public double LocalContrastThreshold { get => _localContrastThreshold; set => SetProperty(ref _localContrastThreshold, value); }
private int _localContrastAbsMin = 0;
public int LocalContrastAbsMin { get => _localContrastAbsMin; set => SetProperty(ref _localContrastAbsMin, value); }
private int _minQualifiedPadArea = 1000;
public int MinQualifiedPadArea { get => _minQualifiedPadArea; set => SetProperty(ref _minQualifiedPadArea, value); }
@@ -253,6 +272,12 @@ namespace XplorePlane.ViewModels.ImageProcessing
processor.SetParameter("VoidMergeRadius", VoidMergeRadius);
processor.SetParameter("VoidRateLimit", VoidRateLimit);
processor.SetParameter("MinQualifiedPadArea", MinQualifiedPadArea);
processor.SetParameter("VoidDetectionMode", VoidDetectionMode);
processor.SetParameter("VoidSensitivity", VoidSensitivity);
processor.SetParameter("TopHatKernelSize", TopHatKernelSize);
processor.SetParameter("LocalContrastWindowRadius", LocalContrastWindowRadius);
processor.SetParameter("LocalContrastThreshold", LocalContrastThreshold);
processor.SetParameter("LocalContrastAbsMin", LocalContrastAbsMin);
// ROI 注入
if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3)
@@ -380,6 +405,12 @@ namespace XplorePlane.ViewModels.ImageProcessing
parameters["VoidMergeRadius"] = VoidMergeRadius;
parameters["VoidRateLimit"] = VoidRateLimit;
parameters["MinQualifiedPadArea"] = MinQualifiedPadArea;
parameters["VoidDetectionMode"] = VoidDetectionMode;
parameters["VoidSensitivity"] = VoidSensitivity;
parameters["TopHatKernelSize"] = TopHatKernelSize;
parameters["LocalContrastWindowRadius"] = LocalContrastWindowRadius;
parameters["LocalContrastThreshold"] = LocalContrastThreshold;
parameters["LocalContrastAbsMin"] = LocalContrastAbsMin;
if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3)
{
@@ -91,6 +91,28 @@ namespace XplorePlane.ViewModels.ImageProcessing
private double _voidLimit = 25.0;
public double VoidLimit { get => _voidLimit; set => SetProperty(ref _voidLimit, value); }
// 空隙检测模式:Fixed / TopHat / LocalContrast / AdaptiveStatistics
private string _voidDetectionMode = "Fixed";
public string VoidDetectionMode { get => _voidDetectionMode; set => SetProperty(ref _voidDetectionMode, value); }
// 自适应统计灵敏度(仅 AdaptiveStatistics 模式生效)
private double _voidSensitivity = 2.5;
public double VoidSensitivity { get => _voidSensitivity; set => SetProperty(ref _voidSensitivity, value); }
// 白帽变换核尺寸(仅 TopHat 模式生效)
private int _topHatKernelSize = 15;
public int TopHatKernelSize { get => _topHatKernelSize; set => SetProperty(ref _topHatKernelSize, value); }
// 局部对比度参数(仅 LocalContrast 模式生效)
private int _localContrastWindowRadius = 20;
public int LocalContrastWindowRadius { get => _localContrastWindowRadius; set => SetProperty(ref _localContrastWindowRadius, value); }
private double _localContrastThreshold = 8.0;
public double LocalContrastThreshold { get => _localContrastThreshold; set => SetProperty(ref _localContrastThreshold, value); }
private int _localContrastAbsMin = 0;
public int LocalContrastAbsMin { get => _localContrastAbsMin; set => SetProperty(ref _localContrastAbsMin, value); }
// ROI
private bool _roiEnabled;
public bool RoiEnabled
@@ -430,6 +452,12 @@ namespace XplorePlane.ViewModels.ImageProcessing
processor.SetParameter("MergeRadius", MergeRadius);
processor.SetParameter("BlurSize", BlurSize);
processor.SetParameter("VoidLimit", VoidLimit);
processor.SetParameter("VoidDetectionMode", VoidDetectionMode);
processor.SetParameter("VoidSensitivity", VoidSensitivity);
processor.SetParameter("TopHatKernelSize", TopHatKernelSize);
processor.SetParameter("LocalContrastWindowRadius", LocalContrastWindowRadius);
processor.SetParameter("LocalContrastThreshold", LocalContrastThreshold);
processor.SetParameter("LocalContrastAbsMin", LocalContrastAbsMin);
// ROI 注入(只要有有效的多边形ROI就注入,不依赖绘制模式开关)
if (_roiShape != null && _roiShape.Points.Count >= 3)
@@ -573,6 +601,12 @@ namespace XplorePlane.ViewModels.ImageProcessing
parameters["MergeRadius"] = MergeRadius;
parameters["BlurSize"] = BlurSize;
parameters["VoidLimit"] = VoidLimit;
parameters["VoidDetectionMode"] = VoidDetectionMode;
parameters["VoidSensitivity"] = VoidSensitivity;
parameters["TopHatKernelSize"] = TopHatKernelSize;
parameters["LocalContrastWindowRadius"] = LocalContrastWindowRadius;
parameters["LocalContrastThreshold"] = LocalContrastThreshold;
parameters["LocalContrastAbsMin"] = LocalContrastAbsMin;
// 写入 ROI 参数
if (_roiShape != null && _roiShape.Points.Count >= 3)
@@ -674,28 +708,14 @@ namespace XplorePlane.ViewModels.ImageProcessing
if (voids != null && voids.Count > 0)
{
// 半透明气泡填充
var overlay = colorImage.Clone();
// 空隙轮廓线(不填充,与焊球风格一致)
foreach (var v in voids)
{
if (v.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(v.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(overlay, vvop, 0, new MCvScalar(0, 200, 255), -1);
}
}
CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage);
overlay.Dispose();
// 绘制轮廓 + 编号
foreach (var v in voids)
{
if (v.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(v.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(colorImage, vvop, 0, new MCvScalar(0, 255, 255), 1);
CvInvoke.DrawContours(colorImage, vvop, 0, new MCvScalar(0, 200, 255), 1);
}
CvInvoke.PutText(colorImage, $"#{v.Index}",
new System.Drawing.Point((int)v.CenterX - 8, (int)v.CenterY + 5),
@@ -42,6 +42,8 @@ namespace XplorePlane.Views.Main
private EventHandler _cursorInfoChangedHandler;
private EventHandler _canvasWidthChangedHandler;
private FrameworkElement _mainCanvasCache;
private IReadOnlyDictionary<string, object> _latestDetectionOutputData;
private string _latestDetectionOperatorKey;
private MainViewModel GetMainVm()
{
@@ -69,6 +71,10 @@ namespace XplorePlane.Views.Main
private void OnDetectionOverlayUpdated(object sender, Services.Main.Viewport.DetectionOverlayEventArgs args)
{
// Cache the latest overlay data for "保存结果图像" compositing
_latestDetectionOutputData = args.OutputData;
_latestDetectionOperatorKey = args.OperatorKey;
Dispatcher.BeginInvoke(new Action(() =>
{
_log.Information("[CNC-Overlay][UI] OnDetectionOverlayUpdatedoperatorKey='{0}'outputData键数={1}",
@@ -1597,17 +1603,44 @@ namespace XplorePlane.Views.Main
}
catch { }
if (result16 != null)
BitmapSource baseImage = result16;
if (baseImage == null && DataContext is ViewportPanelViewModel vm && vm.ImageSource is BitmapSource bitmap)
{
baseImage = bitmap;
}
if (baseImage == null)
{
MessageBox.Show("No result image available", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// Step 1: Composite detection overlays (pipeline results: contours, void labels, etc.)
BitmapSource composited = baseImage;
if (_latestDetectionOutputData != null && !string.IsNullOrEmpty(_latestDetectionOperatorKey))
{
var withDetection = DetectionOverlayRenderer.RenderComposite(baseImage, _latestDetectionOutputData, _latestDetectionOperatorKey);
if (withDetection != null)
{
composited = withDetection;
}
}
// Step 2: Composite measurement overlays and other canvas-drawn content
// (measurement lines, ROI shapes, background defect overlays, template match overlays, etc.)
BitmapSource finalImage = CompositeWithMainCanvasOverlays(composited);
if (finalImage != baseImage)
{
SaveBitmapWithTiffOption(finalImage, "保存结果图像");
}
else if (result16 != null)
{
Save16BitTiffToFile(result16, "保存结果图像");
}
else if (DataContext is ViewportPanelViewModel vm && vm.ImageSource is BitmapSource bitmap)
{
SaveBitmapWithTiffOption(bitmap, "保存结果图像");
}
else
{
MessageBox.Show("No result image available", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
SaveBitmapWithTiffOption(finalImage, "保存结果图像");
}
}
@@ -1627,6 +1660,62 @@ namespace XplorePlane.Views.Main
return visibleNonImageChildren > 0;
}
/// <summary>
/// Composite measurement overlays and other canvas-drawn content (measurement lines,
/// ROI shapes, background defect overlays, template match overlays, etc.) on top of
/// the given base image at full image resolution. Returns the base image as-is if
/// no overlay content is present.
/// </summary>
private BitmapSource CompositeWithMainCanvasOverlays(BitmapSource baseImage)
{
var mainCanvas = FindChildByName<Canvas>(RoiCanvas, "mainCanvas");
if (mainCanvas == null || !HasVisibleOverlayLayers(mainCanvas))
return baseImage;
double width = RoiCanvas.CanvasWidth;
double height = RoiCanvas.CanvasHeight;
if (width <= 0 || height <= 0)
return baseImage;
int pixelWidth = (int)width;
int pixelHeight = (int)height;
// Step 1: Render base image into a RenderTargetBitmap
var dv = new DrawingVisual();
using (var dc = dv.RenderOpen())
{
dc.DrawImage(baseImage, new Rect(0, 0, pixelWidth, pixelHeight));
}
var rtb = new RenderTargetBitmap(pixelWidth, pixelHeight, 96, 96, PixelFormats.Pbgra32);
rtb.Render(dv);
// Step 2: Render overlay layers on top (hide backgroundImage temporarily so only overlays render)
var mainBackgroundImage = mainCanvas.Children.OfType<Image>()
.FirstOrDefault(img => img.Name == "backgroundImage");
Visibility originalVisibility = Visibility.Visible;
if (mainBackgroundImage != null)
{
originalVisibility = mainBackgroundImage.Visibility;
mainBackgroundImage.Visibility = Visibility.Collapsed;
}
// Ensure mainCanvas is arranged at the correct image-pixel size before rendering
mainCanvas.Measure(new Size(pixelWidth, pixelHeight));
mainCanvas.Arrange(new Rect(0, 0, pixelWidth, pixelHeight));
rtb.Render(mainCanvas);
// Restore background image visibility
if (mainBackgroundImage != null)
{
mainBackgroundImage.Visibility = originalVisibility;
}
rtb.Freeze();
return rtb;
}
/// <summary>
/// Save a BitmapSource with TIFF as first option, plus PNG/BMP/JPEG.
/// </summary>