合并 Develop/XP(PR 200: 气泡检测算法更新)
This commit is contained in:
@@ -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] OnDetectionOverlayUpdated:operatorKey='{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>
|
||||
|
||||
Reference in New Issue
Block a user