From 0f24209e1399be5349143e8c47841a15d544781a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Tue, 26 May 2026 11:22:04 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E6=96=B0=E5=A2=9EQFN=E5=BC=95=E8=84=9A?= =?UTF-8?q?=E7=A9=BA=E6=B4=9E=E7=8E=87=E6=A3=80=E6=B5=8B=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=99=A8=E5=8F=8A=E6=9C=AC=E5=9C=B0=E5=8C=96=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XP.Common/Resources/Resources.en-US.resx | 98 ++++ XP.Common/Resources/Resources.resx | 98 ++++ XP.Common/Resources/Resources.zh-CN.resx | 98 ++++ XP.Common/Resources/Resources.zh-TW.resx | 98 ++++ .../检测分析/QfnLeadPadVoidProcessor.cs | 510 ++++++++++++++++++ 5 files changed, 902 insertions(+) create mode 100644 XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs diff --git a/XP.Common/Resources/Resources.en-US.resx b/XP.Common/Resources/Resources.en-US.resx index b115e45..dd6efdd 100644 --- a/XP.Common/Resources/Resources.en-US.resx +++ b/XP.Common/Resources/Resources.en-US.resx @@ -2008,4 +2008,102 @@ Reprojection error: {1:F4} pixels Histogram — No data ImageHistogramControl - Placeholder text when no image data + + + + QFN Lead Pad Void Detection + + + Automatically detect QFN lead pads and measure void rate per pad (two-step: locate pads → detect voids) + + + ROI Mode + + + None: Full image; Polygon: Polygon ROI (select lead pad area) + + + Pad Blur Size + + + Gaussian blur kernel size for pad detection (odd number) + + + Pad Threshold Low + + + Lower gray threshold for pad segmentation (pads are dark regions) + + + Pad Threshold High + + + Upper gray threshold for pad segmentation + + + Morph Kernel Size + + + Closing kernel size to fill small holes inside pads + + + Min Pad Area + + + Minimum pixel area to be recognized as a lead pad (filter noise) + + + Max Pad Area + + + Maximum pixel area for a lead pad (exclude thermal pad) + + + Min Aspect Ratio + + + Minimum aspect ratio for lead pads (QFN pads are elongated, ratio > 1) + + + Void Threshold Low + + + Lower gray threshold for void detection (voids are bright regions) + + + Void Threshold High + + + Upper gray threshold for void detection + + + Min Void Area + + + Areas smaller than this are treated as noise (pixels) + + + Void Merge Radius + + + Dilation radius to merge adjacent voids (0 = no merge) + + + Void Rate Limit (%) + + + Max allowed void rate per lead pad (default 50%, ref IPC-7095) + + + Min Qualified Pad Area + + + Pads with area below this value are marked as FAIL (insufficient solder) + + + Thickness + + + Contour drawing line thickness + \ No newline at end of file diff --git a/XP.Common/Resources/Resources.resx b/XP.Common/Resources/Resources.resx index 2009be3..c5dd687 100644 --- a/XP.Common/Resources/Resources.resx +++ b/XP.Common/Resources/Resources.resx @@ -2041,4 +2041,102 @@ 直方图 — 暂无数据 ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data + + + + QFN引脚空洞率检测 + + + 自动检测QFN引脚焊点并逐引脚测量空洞率(两步法:定位引脚 → 检测空洞) + + + ROI模式 + + + None: 全图检测; Polygon: 多边形ROI(框选引脚区域) + + + 引脚模糊核 + + + 引脚定位时的高斯模糊核大小(奇数) + + + 引脚阈值下限 + + + 引脚分割灰度下限(焊点为暗区域) + + + 引脚阈值上限 + + + 引脚分割灰度上限 + + + 形态学核大小 + + + 闭运算核大小,用于填充引脚内部小孔洞 + + + 引脚最小面积 + + + 识别为引脚焊点的最小像素面积(过滤噪声) + + + 引脚最大面积 + + + 识别为引脚焊点的最大像素面积(排除散热焊盘) + + + 最小长宽比 + + + 引脚最小长宽比(QFN引脚为长条形,长宽比>1) + + + 空洞阈值下限 + + + 空洞检测灰度下限(空洞为亮区域) + + + 空洞阈值上限 + + + 空洞检测灰度上限 + + + 最小空洞面积 + + + 小于此面积的区域视为噪点忽略(像素) + + + 空洞合并半径 + + + 相邻空洞合并的膨胀半径(0=不合并) + + + 空洞率限值(%) + + + 单引脚最大允许空洞率(默认50%,参考IPC-7095) + + + 引脚合格面积 + + + 引脚面积低于此值判定为不合格(焊料不足) + + + 线条粗细 + + + 轮廓绘制线条粗细 + \ No newline at end of file diff --git a/XP.Common/Resources/Resources.zh-CN.resx b/XP.Common/Resources/Resources.zh-CN.resx index 87926f0..bfbb2cb 100644 --- a/XP.Common/Resources/Resources.zh-CN.resx +++ b/XP.Common/Resources/Resources.zh-CN.resx @@ -2002,4 +2002,102 @@ 直方图 — 暂无数据 ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data + + + + QFN引脚空洞率检测 + + + 自动检测QFN引脚焊点并逐引脚测量空洞率(两步法:定位引脚 → 检测空洞) + + + ROI模式 + + + None: 全图检测; Polygon: 多边形ROI(框选引脚区域) + + + 引脚模糊核 + + + 引脚定位时的高斯模糊核大小(奇数) + + + 引脚阈值下限 + + + 引脚分割灰度下限(焊点为暗区域) + + + 引脚阈值上限 + + + 引脚分割灰度上限 + + + 形态学核大小 + + + 闭运算核大小,用于填充引脚内部小孔洞 + + + 引脚最小面积 + + + 识别为引脚焊点的最小像素面积(过滤噪声) + + + 引脚最大面积 + + + 识别为引脚焊点的最大像素面积(排除散热焊盘) + + + 最小长宽比 + + + 引脚最小长宽比(QFN引脚为长条形,长宽比>1) + + + 空洞阈值下限 + + + 空洞检测灰度下限(空洞为亮区域) + + + 空洞阈值上限 + + + 空洞检测灰度上限 + + + 最小空洞面积 + + + 小于此面积的区域视为噪点忽略(像素) + + + 空洞合并半径 + + + 相邻空洞合并的膨胀半径(0=不合并) + + + 空洞率限值(%) + + + 单引脚最大允许空洞率(默认50%,参考IPC-7095) + + + 引脚合格面积 + + + 引脚面积低于此值判定为不合格(焊料不足) + + + 线条粗细 + + + 轮廓绘制线条粗细 + \ No newline at end of file diff --git a/XP.Common/Resources/Resources.zh-TW.resx b/XP.Common/Resources/Resources.zh-TW.resx index 630a189..7ed6c20 100644 --- a/XP.Common/Resources/Resources.zh-TW.resx +++ b/XP.Common/Resources/Resources.zh-TW.resx @@ -1885,4 +1885,102 @@ 直方圖 — 暫無資料 ImageHistogramControl - 無圖像輸入時的提示文字 | Placeholder text when no image data + + + + QFN引腳空洞率檢測 + + + 自動檢測QFN引腳焊點並逐引腳測量空洞率(兩步法:定位引腳 → 檢測空洞) + + + ROI模式 + + + None: 全圖檢測; Polygon: 多邊形ROI(框選引腳區域) + + + 引腳模糊核 + + + 引腳定位時的高斯模糊核大小(奇數) + + + 引腳閾值下限 + + + 引腳分割灰度下限(焊點為暗區域) + + + 引腳閾值上限 + + + 引腳分割灰度上限 + + + 形態學核大小 + + + 閉運算核大小,用於填充引腳內部小孔洞 + + + 引腳最小面積 + + + 識別為引腳焊點的最小像素面積(過濾噪聲) + + + 引腳最大面積 + + + 識別為引腳焊點的最大像素面積(排除散熱焊盤) + + + 最小長寬比 + + + 引腳最小長寬比(QFN引腳為長條形,長寬比>1) + + + 空洞閾值下限 + + + 空洞檢測灰度下限(空洞為亮區域) + + + 空洞閾值上限 + + + 空洞檢測灰度上限 + + + 最小空洞面積 + + + 小於此面積的區域視為噪點忽略(像素) + + + 空洞合併半徑 + + + 相鄰空洞合併的膨脹半徑(0=不合併) + + + 空洞率限值(%) + + + 單引腳最大允許空洞率(預設50%,參考IPC-7095) + + + 引腳合格面積 + + + 引腳面積低於此值判定為不合格(焊料不足) + + + 線條粗細 + + + 輪廓繪製線條粗細 + \ No newline at end of file diff --git a/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs b/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs new file mode 100644 index 0000000..5977eae --- /dev/null +++ b/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs @@ -0,0 +1,510 @@ +// ============================================================================ +// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. +// 文件名: QfnLeadPadVoidProcessor.cs +// 描述: QFN 单引脚空洞率检测算子 +// +// 处理流程: +// 第一步 — 引脚定位: 高斯模糊 → 双阈值分割 → 形态学闭运算 → 轮廓检测 +// → 面积/长宽比过滤 → 排除散热焊盘 → 引脚排序 +// 第二步 — 空洞检测: 逐引脚掩码 → 双阈值分割 → 轮廓检测 → 面积过滤 → 空洞率计算 +// +// 支持多边形ROI限定检测区域(框选引脚所在边),支持IPC-7095标准PASS/FAIL判定 +// 正片模式:焊点=暗区域,空洞=亮区域 +// +// 作者: 李伟 wei.lw.li@hexagon.com +// ============================================================================ + +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using XP.ImageProcessing.Core; +using Serilog; +using System.Drawing; + +namespace XP.ImageProcessing.Processors; + +public class QfnLeadPadVoidProcessor : ImageProcessorBase +{ + private static readonly ILogger _logger = Log.ForContext(); + + public QfnLeadPadVoidProcessor() + { + Name = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Name"); + Description = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Description"); + } + + protected override void InitializeParameters() + { + // ── ROI限定区域 ── + Parameters.Add("RoiMode", new ProcessorParameter( + "RoiMode", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode"), + typeof(string), "None", null, null, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode_Desc"), + new string[] { "None", "Polygon" })); + + // 多边形ROI点数和坐标(由UI注入,不可见,最多支持32个点) + Parameters.Add("PolyCount", new ProcessorParameter("PolyCount", "PolyCount", typeof(int), 0, null, null, "") { IsVisible = false }); + for (int i = 0; i < 32; i++) + { + Parameters.Add($"PolyX{i}", new ProcessorParameter($"PolyX{i}", $"PolyX{i}", typeof(int), 0, null, null, "") { IsVisible = false }); + Parameters.Add($"PolyY{i}", new ProcessorParameter($"PolyY{i}", $"PolyY{i}", typeof(int), 0, null, null, "") { IsVisible = false }); + } + + // ── 第一步:引脚定位参数 ── + Parameters.Add("PadBlurSize", new ProcessorParameter( + "PadBlurSize", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize"), + typeof(int), 5, 1, 31, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize_Desc"))); + + Parameters.Add("PadThresholdLow", new ProcessorParameter( + "PadThresholdLow", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow"), + typeof(int), 0, 0, 255, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow_Desc"))); + + Parameters.Add("PadThresholdHigh", new ProcessorParameter( + "PadThresholdHigh", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh"), + typeof(int), 120, 0, 255, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh_Desc"))); + + Parameters.Add("PadMorphKernel", new ProcessorParameter( + "PadMorphKernel", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel"), + typeof(int), 5, 1, 31, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel_Desc"))); + + Parameters.Add("MinPadArea", new ProcessorParameter( + "MinPadArea", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea"), + typeof(int), 200, 10, 1000000, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea_Desc"))); + + Parameters.Add("MaxPadArea", new ProcessorParameter( + "MaxPadArea", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea"), + typeof(int), 100000, 100, 10000000, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea_Desc"))); + + Parameters.Add("PadAspectRatioMin", new ProcessorParameter( + "PadAspectRatioMin", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin"), + typeof(double), 1.2, 0.1, 20.0, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc"))); + + // ── 第二步:空洞检测参数 ── + Parameters.Add("VoidThresholdLow", new ProcessorParameter( + "VoidThresholdLow", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow"), + typeof(int), 128, 0, 255, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow_Desc"))); + + Parameters.Add("VoidThresholdHigh", new ProcessorParameter( + "VoidThresholdHigh", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh"), + typeof(int), 255, 0, 255, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc"))); + + Parameters.Add("MinVoidArea", new ProcessorParameter( + "MinVoidArea", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea"), + typeof(int), 5, 1, 10000, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea_Desc"))); + + Parameters.Add("VoidMergeRadius", new ProcessorParameter( + "VoidMergeRadius", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius"), + typeof(int), 2, 0, 20, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius_Desc"))); + + Parameters.Add("VoidRateLimit", new ProcessorParameter( + "VoidRateLimit", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit"), + typeof(double), 50.0, 0.0, 100.0, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit_Desc"))); + + Parameters.Add("MinQualifiedPadArea", new ProcessorParameter( + "MinQualifiedPadArea", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea"), + typeof(int), 1000, 0, 1000000, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc"))); + + Parameters.Add("Thickness", new ProcessorParameter( + "Thickness", + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness"), + typeof(int), 2, 1, 10, + LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness_Desc"))); + } + + public override Image Process(Image inputImage) + { + // 读取参数 + string roiMode = GetParameter("RoiMode"); + int padBlurSize = GetParameter("PadBlurSize"); + int padThreshLow = GetParameter("PadThresholdLow"); + int padThreshHigh = GetParameter("PadThresholdHigh"); + int padMorphKernel = GetParameter("PadMorphKernel"); + int minPadArea = GetParameter("MinPadArea"); + int maxPadArea = GetParameter("MaxPadArea"); + double padAspectRatioMin = GetParameter("PadAspectRatioMin"); + int voidThreshLow = GetParameter("VoidThresholdLow"); + int voidThreshHigh = GetParameter("VoidThresholdHigh"); + int minVoidArea = GetParameter("MinVoidArea"); + int voidMergeRadius = GetParameter("VoidMergeRadius"); + double voidRateLimit = GetParameter("VoidRateLimit"); + int minQualifiedPadArea = GetParameter("MinQualifiedPadArea"); + int thickness = GetParameter("Thickness"); + + // 确保模糊核为奇数 + if (padBlurSize % 2 == 0) padBlurSize++; + if (padMorphKernel % 2 == 0) padMorphKernel++; + + OutputData.Clear(); + int w = inputImage.Width, h = inputImage.Height; + + // ── 构建ROI掩码 ── + Image? roiMask = null; + if (roiMode == "Polygon") + { + int polyCount = GetParameter("PolyCount"); + if (polyCount >= 3) + { + var pts = new Point[polyCount]; + for (int i = 0; i < polyCount; i++) + pts[i] = new Point(GetParameter($"PolyX{i}"), GetParameter($"PolyY{i}")); + roiMask = new Image(w, h); + using var vop = new VectorOfPoint(pts); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(roiMask, vvop, 0, new MCvScalar(255), -1); + _logger.Debug("QFN Lead ROI Polygon: {Count} points", polyCount); + } + } + + OutputData["RoiMode"] = roiMode; + OutputData["RoiMask"] = roiMask; + + _logger.Debug("QfnLeadPadVoid: PadArea=[{Min},{Max}], Blur={Blur}, PadThresh=[{TLow},{THigh}], AspectMin={Asp}", + minPadArea, maxPadArea, padBlurSize, padThreshLow, padThreshHigh, padAspectRatioMin); + + // ================================================================ + // 第一步:自动检测QFN引脚焊点位置 + // ================================================================ + var leadPads = DetectLeadPads(inputImage, padBlurSize, padThreshLow, padThreshHigh, + padMorphKernel, minPadArea, maxPadArea, padAspectRatioMin, roiMask); + + _logger.Information("第一步完成: 检测到 {Count} 个QFN引脚焊点", leadPads.Count); + + if (leadPads.Count == 0) + { + OutputData["QfnLeadResult"] = true; + OutputData["LeadCount"] = 0; + OutputData["LeadPads"] = leadPads; + OutputData["VoidRate"] = 0.0; + OutputData["VoidRateLimit"] = voidRateLimit; + OutputData["Classification"] = "N/A"; + OutputData["ResultText"] = "No QFN lead pads detected"; + OutputData["Thickness"] = thickness; + OutputData["TotalPadArea"] = 0; + OutputData["TotalVoidArea"] = 0; + OutputData["TotalVoidCount"] = 0; + roiMask?.Dispose(); + return inputImage.Clone(); + } + + // ================================================================ + // 第二步:在每个引脚焊点区域内检测空洞 + // ================================================================ + int totalPadArea = 0; + int totalVoidArea = 0; + int totalVoidCount = 0; + + foreach (var pad in leadPads) + { + DetectVoidsInLeadPad(inputImage, pad, voidThreshLow, voidThreshHigh, minVoidArea, voidMergeRadius); + totalPadArea += pad.PadArea; + totalVoidArea += pad.VoidPixels; + totalVoidCount += pad.Voids.Count; + } + + double overallVoidRate = totalPadArea > 0 ? (double)totalVoidArea / totalPadArea * 100.0 : 0; + string classification = "PASS"; + + // 判定:空洞率超标或面积不足均为FAIL + int failCount = 0; + double maxSingleVoidRate = 0; + foreach (var pad in leadPads) + { + if (pad.PadArea < minQualifiedPadArea) + pad.Classification = "FAIL_AREA"; + else if (pad.VoidRate > voidRateLimit) + pad.Classification = "FAIL"; + else + pad.Classification = "PASS"; + + if (pad.Classification != "PASS") failCount++; + if (pad.VoidRate > maxSingleVoidRate) maxSingleVoidRate = pad.VoidRate; + } + if (failCount > 0) classification = "FAIL"; + + _logger.Information("第二步完成: 总空洞率={VoidRate:F1}%, 最大单引脚={MaxRate:F1}%, 不合格={Fail}/{Total}, 判定={Class}", + overallVoidRate, maxSingleVoidRate, failCount, leadPads.Count, classification); + + // ── 输出数据 ── + OutputData["QfnLeadResult"] = true; + OutputData["LeadCount"] = leadPads.Count; + OutputData["LeadPads"] = leadPads; + OutputData["VoidRate"] = overallVoidRate; + OutputData["MaxSingleVoidRate"] = maxSingleVoidRate; + OutputData["VoidRateLimit"] = voidRateLimit; + OutputData["TotalPadArea"] = totalPadArea; + OutputData["TotalVoidArea"] = totalVoidArea; + OutputData["TotalVoidCount"] = totalVoidCount; + OutputData["FailCount"] = failCount; + OutputData["Classification"] = classification; + OutputData["Thickness"] = thickness; + OutputData["ResultText"] = $"QFN Lead: {overallVoidRate:F1}% | {classification} | {leadPads.Count} pads | Fail: {failCount}"; + + roiMask?.Dispose(); + return inputImage.Clone(); + } + + /// + /// 第一步:自动检测QFN引脚焊点位置 + /// 使用双阈值分割 + 形态学 + 轮廓检测 + 面积/长宽比过滤 + /// QFN引脚在X-Ray正片中为暗色长条形区域 + /// + private List DetectLeadPads( + Image input, int blurSize, + int threshLow, int threshHigh, int morphKernel, + int minArea, int maxArea, double aspectRatioMin, + Image? roiMask) + { + var results = new List(); + int w = input.Width, h = input.Height; + + // 高斯模糊降噪 + var blurred = new Image(w, h); + CvInvoke.GaussianBlur(input, blurred, new Size(blurSize, blurSize), 0); + + // 双阈值分割(X-Ray正片:焊点=暗区域,灰度在[threshLow, threshHigh]范围内判为焊点) + var binary = new Image(w, h); + byte[,,] srcData = blurred.Data; + byte[,,] dstData = binary.Data; + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + byte val = srcData[y, x, 0]; + dstData[y, x, 0] = (val >= threshLow && val <= threshHigh) ? (byte)255 : (byte)0; + } + } + + // 如果有ROI掩码,只保留ROI区域内的结果 + if (roiMask != null) + { + CvInvoke.BitwiseAnd(binary, roiMask, binary); + } + + // 形态学闭运算填充引脚内部小孔洞 + using var kernel = CvInvoke.GetStructuringElement(ElementShape.Rectangle, + new Size(morphKernel, morphKernel), new Point(-1, -1)); + CvInvoke.MorphologyEx(binary, binary, MorphOp.Close, kernel, new Point(-1, -1), 2, BorderType.Default, new MCvScalar(0)); + + // 查找轮廓 + using var contours = new VectorOfVectorOfPoint(); + using var hierarchy = new Mat(); + CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + for (int i = 0; i < contours.Size; i++) + { + double area = CvInvoke.ContourArea(contours[i]); + if (area < minArea || area > maxArea) continue; + + // 需要至少5个点才能拟合椭圆/旋转矩形 + if (contours[i].Size < 5) continue; + + // 最小外接旋转矩形 + var minRect = CvInvoke.MinAreaRect(contours[i]); + float rectWidth = Math.Max(minRect.Size.Width, minRect.Size.Height); + float rectHeight = Math.Min(minRect.Size.Width, minRect.Size.Height); + + // 长宽比过滤:QFN引脚是长条形,长宽比应大于阈值 + if (rectHeight < 1) continue; + double aspectRatio = rectWidth / rectHeight; + if (aspectRatio < aspectRatioMin) continue; + + var moments = CvInvoke.Moments(contours[i]); + if (moments.M00 < 1) continue; + + results.Add(new QfnLeadPadInfo + { + CenterX = moments.M10 / moments.M00, + CenterY = moments.M01 / moments.M00, + BoundingRotatedRect = minRect, + ContourPoints = contours[i].ToArray(), + PadArea = (int)area, + AspectRatio = aspectRatio + }); + } + + // 按角度位置排序(从图像中心出发,逆时针排列) + SortLeadPadsByPosition(results, w, h); + + blurred.Dispose(); + binary.Dispose(); + + return results; + } + + /// + /// 按引脚在图像中的位置排序(从左上角开始逆时针) + /// 先按所在边分组(上/右/下/左),再在每条边内按位置排序 + /// + private void SortLeadPadsByPosition(List pads, int imageWidth, int imageHeight) + { + if (pads.Count == 0) return; + + double cx = imageWidth / 2.0; + double cy = imageHeight / 2.0; + + // 按角度排序(从正上方开始顺时针) + // atan2 返回 [-π, π],调整为从正上方开始 + pads.Sort((a, b) => + { + double angleA = Math.Atan2(a.CenterX - cx, -(a.CenterY - cy)); + double angleB = Math.Atan2(b.CenterX - cx, -(b.CenterY - cy)); + if (angleA < 0) angleA += 2 * Math.PI; + if (angleB < 0) angleB += 2 * Math.PI; + return angleA.CompareTo(angleB); + }); + + // 重新编号 + for (int i = 0; i < pads.Count; i++) + pads[i].Index = i + 1; + } + + /// + /// 第二步:在单个引脚焊点区域内检测空洞 + /// 使用引脚轮廓作为掩码,双阈值分割空洞区域 + /// + private void DetectVoidsInLeadPad( + Image input, QfnLeadPadInfo pad, + int voidThreshLow, int voidThreshHigh, int minVoidArea, int mergeRadius) + { + int w = input.Width, h = input.Height; + + // 创建该引脚的掩码 + var mask = new Image(w, h); + using (var vop = new VectorOfPoint(pad.ContourPoints)) + using (var vvop = new VectorOfVectorOfPoint(vop)) + { + CvInvoke.DrawContours(mask, vvop, 0, new MCvScalar(255), -1); + } + + int padPixels = CvInvoke.CountNonZero(mask); + pad.PadArea = padPixels; + + // 双阈值分割(正片模式:空洞=亮区域,灰度在[voidThreshLow, voidThreshHigh]范围内判为空洞) + var voidImg = new Image(w, h); + byte[,,] srcData = input.Data; + byte[,,] dstData = voidImg.Data; + byte[,,] maskData = mask.Data; + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + if (maskData[y, x, 0] > 0) + { + byte val = srcData[y, x, 0]; + dstData[y, x, 0] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0; + } + } + } + + // 形态学膨胀合并相邻空洞 + if (mergeRadius > 0) + { + int kernelSize = mergeRadius * 2 + 1; + using var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse, + new Size(kernelSize, kernelSize), new Point(-1, -1)); + CvInvoke.Dilate(voidImg, voidImg, kernel, new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0)); + // 与引脚掩码取交集,防止膨胀超出引脚区域 + CvInvoke.BitwiseAnd(voidImg, mask, voidImg); + } + + // 检测每个空洞的轮廓 + using var contours = new VectorOfVectorOfPoint(); + using var hierarchy = new Mat(); + CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + int filteredVoidArea = 0; + for (int i = 0; i < contours.Size; i++) + { + double area = CvInvoke.ContourArea(contours[i]); + if (area < minVoidArea) continue; + + var moments = CvInvoke.Moments(contours[i]); + if (moments.M00 < 1) continue; + + filteredVoidArea += (int)Math.Round(area); + pad.Voids.Add(new QfnLeadVoidInfo + { + Index = pad.Voids.Count + 1, + CenterX = moments.M10 / moments.M00, + CenterY = moments.M01 / moments.M00, + Area = area, + AreaPercent = padPixels > 0 ? area / padPixels * 100.0 : 0, + BoundingBox = CvInvoke.BoundingRectangle(contours[i]), + ContourPoints = contours[i].ToArray() + }); + } + + // 空洞率基于过滤后的轮廓面积计算 + pad.VoidPixels = filteredVoidArea; + pad.VoidRate = padPixels > 0 ? (double)filteredVoidArea / padPixels * 100.0 : 0; + + // 按面积从大到小排序 + pad.Voids.Sort((a, b) => b.Area.CompareTo(a.Area)); + for (int i = 0; i < pad.Voids.Count; i++) pad.Voids[i].Index = i + 1; + + mask.Dispose(); + voidImg.Dispose(); + } +} + +/// +/// 单个QFN引脚焊点信息 +/// +public class QfnLeadPadInfo +{ + public int Index { get; set; } + public double CenterX { get; set; } + public double CenterY { get; set; } + public RotatedRect BoundingRotatedRect { get; set; } + public Point[] ContourPoints { get; set; } = Array.Empty(); + public int PadArea { get; set; } + public double AspectRatio { get; set; } + public int VoidPixels { get; set; } + public double VoidRate { get; set; } + public string Classification { get; set; } = "N/A"; + public List Voids { get; set; } = new(); +} + +/// +/// 单个引脚内的空洞信息 +/// +public class QfnLeadVoidInfo +{ + public int Index { get; set; } + public double CenterX { get; set; } + public double CenterY { get; set; } + public double Area { get; set; } + public double AreaPercent { get; set; } + public Rectangle BoundingBox { get; set; } + public Point[] ContourPoints { get; set; } = Array.Empty(); +} From 7c06cd2def453e8815a086c890a1d24138576438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Tue, 26 May 2026 11:22:17 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E6=96=B0=E5=A2=9EQFN=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=8F=8A=E6=8C=89=E9=92=AE=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=B8=89=E4=B8=AA=E6=A8=A1=E5=9D=97ROI=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=E4=B8=BACyan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageProcessing/BgaDetectionViewModel.cs | 2 +- .../QfnLeadPadDetectionViewModel.cs | 549 ++++++++++++++++++ XplorePlane/ViewModels/Main/MainViewModel.cs | 22 + .../QfnLeadPadDetectionPanel.xaml | 226 +++++++ .../QfnLeadPadDetectionPanel.xaml.cs | 59 ++ XplorePlane/Views/Main/MainWindow.xaml | 11 +- 6 files changed, 866 insertions(+), 3 deletions(-) create mode 100644 XplorePlane/ViewModels/ImageProcessing/QfnLeadPadDetectionViewModel.cs create mode 100644 XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml create mode 100644 XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml.cs diff --git a/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs index f30ea24..691e805 100644 --- a/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs @@ -121,7 +121,7 @@ namespace XplorePlane.ViewModels.ImageProcessing if (_canvas.ROIItems == null) _canvas.ROIItems = new ObservableCollection(); - _roiShape = new PolygonROI { Color = "Red", IsSelected = true }; + _roiShape = new PolygonROI { Color = "Cyan", IsSelected = true }; _canvas.ROIItems.Add(_roiShape); _canvas.SelectedROI = _roiShape; diff --git a/XplorePlane/ViewModels/ImageProcessing/QfnLeadPadDetectionViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/QfnLeadPadDetectionViewModel.cs new file mode 100644 index 0000000..b3b2dd8 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/QfnLeadPadDetectionViewModel.cs @@ -0,0 +1,549 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using Prism.Commands; +using Prism.Mvvm; +using XP.ImageProcessing.Processors; +using XP.ImageProcessing.RoiControl.Controls; +using XP.ImageProcessing.RoiControl.Models; +using XplorePlane.Models; +using XplorePlane.Services.MainViewport; +using XplorePlane.ViewModels.Cnc; + +namespace XplorePlane.ViewModels.ImageProcessing +{ + public class QfnLeadPadDetectionViewModel : BindableBase + { + private readonly IMainViewportService _viewportService; + private CncEditorViewModel _cncEditorViewModel; + private BitmapSource _originalImage; + private System.Threading.CancellationTokenSource _debounceCts; + private const int DebounceMs = 300; + private const string QfnLeadPadOperatorKey = "QfnLeadPadVoid"; + + public QfnLeadPadDetectionViewModel(IMainViewportService viewportService) + { + _viewportService = viewportService; + ExecuteCommand = new DelegateCommand(Execute); + InsertToCncCommand = new DelegateCommand(ExecuteInsertToCnc); + PropertyChanged += OnAnyPropertyChanged; + } + + public void SetCncEditorViewModel(CncEditorViewModel cncEditorViewModel) + { + _cncEditorViewModel = cncEditorViewModel; + } + + private void OnAnyPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ResultText) || e.PropertyName == nameof(ResultImage) || e.PropertyName == nameof(RoiEnabled)) + return; + TriggerDebouncedExecution(); + } + + private void TriggerDebouncedExecution() + { + _debounceCts?.Cancel(); + _debounceCts = new System.Threading.CancellationTokenSource(); + var token = _debounceCts.Token; + System.Threading.Tasks.Task.Delay(DebounceMs, token).ContinueWith(t => + { + if (!t.IsCanceled) Execute(); + }, System.Threading.Tasks.TaskScheduler.FromCurrentSynchronizationContext()); + } + + // ── 引脚定位参数 ── + private int _padBlurSize = 5; + public int PadBlurSize { get => _padBlurSize; set => SetProperty(ref _padBlurSize, value); } + + private int _padThresholdLow = 0; + public int PadThresholdLow { get => _padThresholdLow; set => SetProperty(ref _padThresholdLow, value); } + + private int _padThresholdHigh = 120; + public int PadThresholdHigh { get => _padThresholdHigh; set => SetProperty(ref _padThresholdHigh, value); } + + private int _padMorphKernel = 5; + public int PadMorphKernel { get => _padMorphKernel; set => SetProperty(ref _padMorphKernel, value); } + + private int _minPadArea = 200; + public int MinPadArea { get => _minPadArea; set => SetProperty(ref _minPadArea, value); } + + private int _maxPadArea = 100000; + public int MaxPadArea { get => _maxPadArea; set => SetProperty(ref _maxPadArea, value); } + + private double _padAspectRatioMin = 1.2; + public double PadAspectRatioMin { get => _padAspectRatioMin; set => SetProperty(ref _padAspectRatioMin, value); } + + // ── 空洞检测参数 ── + private int _voidThresholdLow = 128; + public int VoidThresholdLow { get => _voidThresholdLow; set => SetProperty(ref _voidThresholdLow, value); } + + private int _voidThresholdHigh = 255; + public int VoidThresholdHigh { get => _voidThresholdHigh; set => SetProperty(ref _voidThresholdHigh, value); } + + private int _minVoidArea = 5; + public int MinVoidArea { get => _minVoidArea; set => SetProperty(ref _minVoidArea, value); } + + private int _voidMergeRadius = 2; + public int VoidMergeRadius { get => _voidMergeRadius; set => SetProperty(ref _voidMergeRadius, value); } + + private double _voidRateLimit = 50.0; + public double VoidRateLimit { get => _voidRateLimit; set => SetProperty(ref _voidRateLimit, value); } + + private int _minQualifiedPadArea = 1000; + public int MinQualifiedPadArea { get => _minQualifiedPadArea; set => SetProperty(ref _minQualifiedPadArea, value); } + + // ── ROI ── + private bool _roiEnabled; + public bool RoiEnabled + { + get => _roiEnabled; + set + { + if (SetProperty(ref _roiEnabled, value)) + OnRoiEnabledChanged(); + } + } + + private PolygonRoiCanvas _canvas; + private PolygonROI _roiShape; + private System.Windows.Controls.Image _resultOverlayImage; + + public void SetCanvas(PolygonRoiCanvas canvas) => _canvas = canvas; + + private void OnRoiEnabledChanged() + { + if (_canvas == null) return; + if (RoiEnabled) + { + if (_canvas.ROIItems == null) + _canvas.ROIItems = new ObservableCollection(); + _roiShape = new PolygonROI { Color = "Cyan", IsSelected = true }; + _canvas.ROIItems.Add(_roiShape); + _canvas.SelectedROI = _roiShape; + _roiShape.Points.CollectionChanged += (s, e) => + { + if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add || + e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove) + { + _canvas.SelectedROI = null; + _canvas.SelectedROI = _roiShape; + } + }; + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, false); + _canvas.AddHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + } + else + { + CleanupRoi(); + } + } + + private void OnCanvasClickedForRoi(object sender, RoutedEventArgs e) + { + if (!RoiEnabled || _roiShape == null) return; + if (e is CanvasClickedEventArgs args) + { + InsertPointToPolygon(args.Position, _roiShape.Points); + _roiShape.IsSelected = true; + _canvas.SelectedROI = _roiShape; + } + } + + public void CleanupRoi() + { + if (_canvas != null) + { + _canvas.RemoveHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, true); + } + if (_roiShape != null && _canvas?.ROIItems != null) + { + _canvas.ROIItems.Remove(_roiShape); + _canvas.SelectedROI = null; + _roiShape = null; + } + } + + public void RestoreContextMenu() + { + if (_canvas != null) + { + _canvas.RemoveHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, true); + if (_roiShape != null) + { + _roiShape.IsSelected = false; + _roiShape.IsEditable = false; + } + _canvas.SelectedROI = null; + } + } + + // ── 结果 ── + private string _resultText = "结果: --"; + public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); } + + private BitmapSource _resultImage; + public BitmapSource ResultImage { get => _resultImage; set => SetProperty(ref _resultImage, value); } + + public ObservableCollection Results { get; } = new(); + + public DelegateCommand ExecuteCommand { get; } + public DelegateCommand InsertToCncCommand { get; } + + private void Execute() + { + if (_originalImage == null) + _originalImage = _viewportService?.CurrentDisplayImage as BitmapSource; + var image = _originalImage; + if (image == null) { ResultText = "请先加载图像"; return; } + + try + { + var processor = new QfnLeadPadVoidProcessor(); + processor.SetParameter("PadBlurSize", PadBlurSize); + processor.SetParameter("PadThresholdLow", PadThresholdLow); + processor.SetParameter("PadThresholdHigh", PadThresholdHigh); + processor.SetParameter("PadMorphKernel", PadMorphKernel); + processor.SetParameter("MinPadArea", MinPadArea); + processor.SetParameter("MaxPadArea", MaxPadArea); + processor.SetParameter("PadAspectRatioMin", PadAspectRatioMin); + processor.SetParameter("VoidThresholdLow", VoidThresholdLow); + processor.SetParameter("VoidThresholdHigh", VoidThresholdHigh); + processor.SetParameter("MinVoidArea", MinVoidArea); + processor.SetParameter("VoidMergeRadius", VoidMergeRadius); + processor.SetParameter("VoidRateLimit", VoidRateLimit); + processor.SetParameter("MinQualifiedPadArea", MinQualifiedPadArea); + + // ROI 注入 + if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3) + { + processor.SetParameter("RoiMode", "Polygon"); + int count = Math.Min(_roiShape.Points.Count, 32); + processor.SetParameter("PolyCount", count); + for (int i = 0; i < count; i++) + { + processor.SetParameter($"PolyX{i}", (int)_roiShape.Points[i].X); + processor.SetParameter($"PolyY{i}", (int)_roiShape.Points[i].Y); + } + } + + var grayImage = BitmapSourceToGray(image); + processor.Process(grayImage); + var output = processor.OutputData; + + ResultText = output.ContainsKey("ResultText") + ? output["ResultText"]?.ToString() ?? "--" + : "未检测到引脚"; + + // 填充结果表格 + Results.Clear(); + if (output.ContainsKey("LeadPads")) + { + var pads = output["LeadPads"] as List; + if (pads != null) + { + foreach (var pad in pads) + { + Results.Add(new QfnLeadPadResultItem + { + Index = pad.Index, + CenterX = pad.CenterX.ToString("F1"), + CenterY = pad.CenterY.ToString("F1"), + PadArea = pad.PadArea.ToString(), + VoidRate = $"{pad.VoidRate:F1}%", + VoidCount = pad.Voids.Count, + Classification = pad.Classification + }); + } + } + } + + ResultImage = RenderResults(grayImage, output); + ShowResultOnOverlay(ResultImage); + grayImage.Dispose(); + } + catch (Exception ex) + { + ResultText = $"错误: {ex.Message}"; + } + } + + private void ExecuteInsertToCnc() + { + if (_cncEditorViewModel == null) + { + MessageBox.Show("CNC 编辑器未就绪,无法插入参数。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var selectedNode = _cncEditorViewModel.SelectedNode; + CncNodeViewModel targetModuleNode = null; + + if (selectedNode == null) + { + MessageBox.Show("请先在 CNC 编辑器中选择一个位置节点或检测模块。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (selectedNode.IsInspectionModule) + targetModuleNode = selectedNode; + else if (selectedNode.IsSavePosition) + targetModuleNode = selectedNode.Children.FirstOrDefault(c => c.IsInspectionModule); + else + { + var allNodes = _cncEditorViewModel.Nodes; + CncNodeViewModel ownerPosition = null; + foreach (var node in allNodes) + { + if (node.IsSavePosition) ownerPosition = node; + if (node.Id == selectedNode.Id) break; + } + if (ownerPosition != null) + targetModuleNode = ownerPosition.Children.FirstOrDefault(c => c.IsInspectionModule); + } + + if (targetModuleNode == null) + { + MessageBox.Show("未找到激活的检测模块节点。\n请在 CNC 编辑器中选择一个包含检测模块的位置节点。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var pipeline = targetModuleNode.Pipeline ?? new PipelineModel { Name = targetModuleNode.Name }; + + var qfnNode = pipeline.Nodes.FirstOrDefault(n => + string.Equals(n.OperatorKey, QfnLeadPadOperatorKey, StringComparison.OrdinalIgnoreCase)); + + if (qfnNode == null) + { + qfnNode = new PipelineNodeModel + { + Id = Guid.NewGuid(), + OperatorKey = QfnLeadPadOperatorKey, + Order = pipeline.Nodes.Count, + IsEnabled = true, + Parameters = new Dictionary() + }; + pipeline.Nodes.Add(qfnNode); + } + + var parameters = qfnNode.Parameters; + parameters["PadBlurSize"] = PadBlurSize; + parameters["PadThresholdLow"] = PadThresholdLow; + parameters["PadThresholdHigh"] = PadThresholdHigh; + parameters["PadMorphKernel"] = PadMorphKernel; + parameters["MinPadArea"] = MinPadArea; + parameters["MaxPadArea"] = MaxPadArea; + parameters["PadAspectRatioMin"] = PadAspectRatioMin; + parameters["VoidThresholdLow"] = VoidThresholdLow; + parameters["VoidThresholdHigh"] = VoidThresholdHigh; + parameters["MinVoidArea"] = MinVoidArea; + parameters["VoidMergeRadius"] = VoidMergeRadius; + parameters["VoidRateLimit"] = VoidRateLimit; + parameters["MinQualifiedPadArea"] = MinQualifiedPadArea; + + if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3) + { + parameters["RoiMode"] = "Polygon"; + int count = Math.Min(_roiShape.Points.Count, 32); + parameters["PolyCount"] = count; + for (int i = 0; i < count; i++) + { + parameters[$"PolyX{i}"] = (int)_roiShape.Points[i].X; + parameters[$"PolyY{i}"] = (int)_roiShape.Points[i].Y; + } + } + else + { + parameters["RoiMode"] = "None"; + parameters["PolyCount"] = 0; + } + + pipeline.UpdatedAt = DateTime.UtcNow; + targetModuleNode.Pipeline = pipeline; + + _cncEditorViewModel.SelectedNode = null; + _cncEditorViewModel.SelectedNode = targetModuleNode; + + MessageBox.Show( + $"已将QFN引脚检测参数插入到检测模块「{targetModuleNode.Name}」。", + "插入成功", MessageBoxButton.OK, MessageBoxImage.Information); + } + + private void ShowResultOnOverlay(BitmapSource resultBmp) + { + if (_canvas == null) return; + RemoveResultOverlay(); + if (resultBmp == null) return; + + _resultOverlayImage = new System.Windows.Controls.Image + { + Source = resultBmp, + IsHitTestVisible = false, + Stretch = System.Windows.Media.Stretch.Fill + }; + _resultOverlayImage.SetBinding(System.Windows.FrameworkElement.WidthProperty, + new System.Windows.Data.Binding("CanvasWidth") { Source = _canvas }); + _resultOverlayImage.SetBinding(System.Windows.FrameworkElement.HeightProperty, + new System.Windows.Data.Binding("CanvasHeight") { Source = _canvas }); + + var mainCanvas = _canvas.FindName("mainCanvas") as System.Windows.Controls.Canvas; + if (mainCanvas != null) + { + int insertIndex = Math.Min(1, mainCanvas.Children.Count); + mainCanvas.Children.Insert(insertIndex, _resultOverlayImage); + } + } + + public void RemoveResultOverlay() + { + if (_resultOverlayImage == null || _canvas == null) return; + _canvas.RemoveFromCanvas(_resultOverlayImage); + _resultOverlayImage = null; + } + + private BitmapSource RenderResults(Image grayImage, IDictionary output) + { + if (!output.ContainsKey("QfnLeadResult")) return null; + int leadCount = (int)output["LeadCount"]; + if (leadCount == 0) return null; + + double voidRate = (double)output["VoidRate"]; + double voidRateLimitVal = (double)output["VoidRateLimit"]; + string classification = (string)output["Classification"]; + var pads = output["LeadPads"] as List; + + var colorImage = new Image(grayImage.Width, grayImage.Height); + CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr); + + if (pads != null && pads.Count > 0) + { + // 半透明引脚填充 + var overlay = colorImage.Clone(); + foreach (var pad in pads) + { + if (pad.ContourPoints.Length > 0) + { + var padColor = pad.Classification == "PASS" + ? new MCvScalar(0, 200, 0) // 绿色 + : new MCvScalar(0, 0, 220); // 红色 + using var vop = new VectorOfPoint(pad.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(overlay, vvop, 0, padColor, -1); + } + } + CvInvoke.AddWeighted(overlay, 0.3, colorImage, 0.7, 0, colorImage); + overlay.Dispose(); + + // 绘制引脚轮廓 + 编号 + 空洞率 + foreach (var pad in pads) + { + var contourColor = pad.Classification == "PASS" + ? new MCvScalar(0, 255, 0) + : new MCvScalar(0, 0, 255); + + if (pad.ContourPoints.Length > 0) + { + using var vop = new VectorOfPoint(pad.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(colorImage, vvop, 0, contourColor, 2); + } + + // 绘制空洞轮廓 + foreach (var v in pad.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.PutText(colorImage, $"#{pad.Index} {pad.VoidRate:F1}%", + new System.Drawing.Point((int)pad.CenterX - 20, (int)pad.CenterY + 5), + FontFace.HersheySimplex, 0.35, contourColor, 1); + } + + // 左上角总览 + var overallColor = classification == "PASS" + ? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255); + CvInvoke.PutText(colorImage, + $"QFN Lead: {voidRate:F1}% | Limit: {voidRateLimitVal:F0}% | {leadCount} pads | {classification}", + new System.Drawing.Point(10, 25), + FontFace.HersheySimplex, 0.5, overallColor, 2); + } + + using var bitmap = colorImage.ToBitmap(); + var bmpSrc = System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap( + bitmap.GetHbitmap(), IntPtr.Zero, System.Windows.Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions()); + bmpSrc.Freeze(); + colorImage.Dispose(); + return bmpSrc; + } + + private static void InsertPointToPolygon(Point newPoint, ObservableCollection points) + { + if (points.Count < 2) { points.Add(newPoint); return; } + int insertIndex = 0; + double minDistance = double.MaxValue; + double d = PointToSegmentDistance(points[points.Count - 1], points[0], newPoint); + if (d < minDistance) { minDistance = d; insertIndex = 0; } + for (int i = 1; i < points.Count; i++) + { + d = PointToSegmentDistance(points[i - 1], points[i], newPoint); + if (d < minDistance) { minDistance = d; insertIndex = i; } + } + points.Insert(insertIndex, newPoint); + } + + private static double PointToSegmentDistance(Point a, Point b, Point p) + { + double dx = b.X - a.X, dy = b.Y - a.Y; + double lenSq = dx * dx + dy * dy; + if (lenSq < 1e-10) return Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y)); + double t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq, 0, 1); + double projX = a.X + t * dx, projY = a.Y + t * dy; + return Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY)); + } + + private static Image BitmapSourceToGray(BitmapSource bmp) + { + var converted = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0); + int w = converted.PixelWidth, h = converted.PixelHeight; + int stride = w * 4; + var pixels = new byte[stride * h]; + converted.CopyPixels(pixels, stride, 0); + var gray = new Image(w, h); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int idx = y * stride + x * 4; + gray.Data[y, x, 0] = (byte)(pixels[idx + 2] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx] * 0.114); + } + return gray; + } + } + + public class QfnLeadPadResultItem + { + public int Index { get; set; } + public string CenterX { get; set; } = ""; + public string CenterY { get; set; } = ""; + public string PadArea { get; set; } = ""; + public string VoidRate { get; set; } = ""; + public int VoidCount { get; set; } + public string Classification { get; set; } = ""; + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index b2648b8..cb537a3 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -171,6 +171,7 @@ namespace XplorePlane.ViewModels public DelegateCommand ThroughHoleFillRateMeasureCommand { get; } public DelegateCommand BgaDetectionCommand { get; } public DelegateCommand VoidDetectionCommand { get; } + public DelegateCommand QfnLeadPadDetectionCommand { get; } public DelegateCommand BubbleMeasureCommand { get; } private bool _isScaleBarVisible; @@ -402,6 +403,7 @@ namespace XplorePlane.ViewModels ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure); BgaDetectionCommand = new DelegateCommand(ExecuteBgaDetection); VoidDetectionCommand = new DelegateCommand(ExecuteVoidDetection); + QfnLeadPadDetectionCommand = new DelegateCommand(ExecuteQfnLeadPadDetection); BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure); // 辅助线命令 @@ -1106,6 +1108,26 @@ namespace XplorePlane.ViewModels _voidDetectionPanel.Show(); } + private Window _qfnLeadPadDetectionPanel; + + private void ExecuteQfnLeadPadDetection() + { + if (!CheckImageLoaded()) return; + _logger.Info("QFN引脚空洞检测功能已触发"); + + if (_qfnLeadPadDetectionPanel != null && _qfnLeadPadDetectionPanel.IsVisible) + { + _qfnLeadPadDetectionPanel.Activate(); + return; + } + + _qfnLeadPadDetectionPanel = new Views.ImageProcessing.QfnLeadPadDetectionPanel + { + Owner = System.Windows.Application.Current.MainWindow + }; + _qfnLeadPadDetectionPanel.Show(); + } + private Window _bubbleMeasurePanel; private void ExecuteBubbleMeasure() diff --git a/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml b/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml new file mode 100644 index 0000000..31177f7 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml.cs b/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml.cs new file mode 100644 index 0000000..6931fe4 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/QfnLeadPadDetectionPanel.xaml.cs @@ -0,0 +1,59 @@ +using System.Windows; +using Prism.Ioc; +using XP.ImageProcessing.RoiControl.Controls; +using XplorePlane.Services.MainViewport; +using XplorePlane.ViewModels.Cnc; +using XplorePlane.ViewModels.ImageProcessing; + +namespace XplorePlane.Views.ImageProcessing +{ + public partial class QfnLeadPadDetectionPanel : Window + { + public QfnLeadPadDetectionPanel() + { + InitializeComponent(); + var viewportService = ContainerLocator.Current?.Resolve(); + DataContext = new QfnLeadPadDetectionViewModel(viewportService); + + Loaded += (s, e) => + { + var mainWin = Owner as MainWindow; + if (mainWin != null) + { + var canvas = FindChild(mainWin); + if (DataContext is QfnLeadPadDetectionViewModel vm) + vm.SetCanvas(canvas); + } + + if (DataContext is QfnLeadPadDetectionViewModel qfnVm && Owner?.DataContext is ViewModels.MainViewModel mainVm) + { + var cncEditorField = mainVm.GetType().GetField("_cncEditorViewModel", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (cncEditorField?.GetValue(mainVm) is CncEditorViewModel cncEditor) + qfnVm.SetCncEditorViewModel(cncEditor); + } + }; + + Closed += (s, e) => + { + if (DataContext is QfnLeadPadDetectionViewModel vm) + vm.RestoreContextMenu(); + }; + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private static T FindChild(DependencyObject parent) where T : DependencyObject + { + int count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < count; i++) + { + var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i); + if (child is T t) return t; + var result = FindChild(child); + if (result != null) return result; + } + return null; + } + } +} diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index f4c2ca3..9b30a1c 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -181,11 +181,11 @@ + Text="线灰度" /> + From 77f6a32eda5e1596465cf17393e47252d10bfb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Tue, 26 May 2026 11:22:28 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E7=BA=BF=E7=81=B0=E5=BA=A6=E5=92=8C?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E7=BA=BF=E7=B2=97=E7=BB=86=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E5=88=86=E8=BE=A8=E7=8E=87=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=EF=BC=8C=E5=9B=BE=E5=83=8F=E5=88=87=E6=8D=A2=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E7=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/PolygonRoiCanvas.xaml.cs | 5 +- XplorePlane/Assets/Icons/QFN.png | Bin 0 -> 747 bytes .../Views/Main/ViewportPanelView.xaml.cs | 78 +++++++++++++++++- 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 XplorePlane/Assets/Icons/QFN.png diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs index 1ddedc0..e046738 100644 --- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs +++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs @@ -444,8 +444,9 @@ namespace XP.ImageProcessing.RoiControl.Controls double w = CanvasWidth, h = CanvasHeight; if (w <= 0 || h <= 0) return; - _crosshairH = new Line { X1 = 0, Y1 = h / 2, X2 = w, Y2 = h / 2, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false }; - _crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false }; + double thickness = Math.Max(1, Math.Round(Math.Max(w, h) / 1000.0)); + _crosshairH = new Line { X1 = 0, Y1 = h / 2, X2 = w, Y2 = h / 2, Stroke = Brushes.Red, StrokeThickness = thickness, Opacity = 0.7, IsHitTestVisible = false }; + _crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = thickness, Opacity = 0.7, IsHitTestVisible = false }; mainCanvas.Children.Add(_crosshairH); mainCanvas.Children.Add(_crosshairV); } diff --git a/XplorePlane/Assets/Icons/QFN.png b/XplorePlane/Assets/Icons/QFN.png new file mode 100644 index 0000000000000000000000000000000000000000..b2dd0e322e85032763cb084ce46be7c333ca663c GIT binary patch literal 747 zcmVRLi$|8qd0aR)O<=>;I35IW)r2Pc@1fmJZR>1!%jAs>4b`C6Zcbslr zaIin0&#NY2q3mcH<%f%<*3JOg>>Myl5;24gz@YQgyqEb zR}GfPULHg`= zyG0))Zop4oNgprgc@^W(nK^*Sli=`!>{d*MYa9EQ%Oy$xANs!Vb~%o<&`-e7o?_Z} zRfm8ka(Fx*_dKK9E-<6=9~mntK{GxUgH0~Sp~L(AZogbG9S?`YLr4OHm!%A@AFzz1 zY6;jo5PB6Ms-jXCeMwY<>j4U5sb3?Y$ruTo`GeE7k1wBjEI9fsN<-$B(!%L18s|K4}EjaHYLZY;PTN}0VxHMAc$^?hJ|hdglwGW`LEbm3kB>x z)TYo%AZ7e7bl-yj*HWcimTgwP70c`OYTpjkF_qC + { + if (_lineProfileEnabled) RedrawLineProfile(); + }); + // 白底检测:进入ROI绘制模式 ea2?.GetEvent().Subscribe(() => { @@ -256,8 +264,12 @@ namespace XplorePlane.Views // 参考线默认在图像中间 _profileLineY = RoiCanvas.CanvasHeight / 2; + // 根据图像分辨率自适应线条粗细 + double maxDim = Math.Max(RoiCanvas.CanvasWidth, RoiCanvas.CanvasHeight); + double lineThickness = Math.Max(1, Math.Round(maxDim / 1000.0)); + // 创建参考线(红色水平线,可拖动) - // 用透明粗线作为命中区域,叠加1px红线显示 + // 用透明粗线作为命中区域,叠加红线显示 _profileRefLine = new System.Windows.Shapes.Line { X1 = 0, @@ -265,7 +277,7 @@ namespace XplorePlane.Views X2 = RoiCanvas.CanvasWidth, Y2 = _profileLineY, Stroke = System.Windows.Media.Brushes.Transparent, - StrokeThickness = 7, // 上下3px命中区域 + StrokeThickness = lineThickness + 6, // 上下命中区域 IsHitTestVisible = true, Cursor = System.Windows.Input.Cursors.SizeNS }; @@ -276,7 +288,7 @@ namespace XplorePlane.Views X2 = RoiCanvas.CanvasWidth, Y2 = _profileLineY, Stroke = System.Windows.Media.Brushes.Red, - StrokeThickness = 1, + StrokeThickness = lineThickness, IsHitTestVisible = false }; _profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown; @@ -289,7 +301,7 @@ namespace XplorePlane.Views _profileCurve = new System.Windows.Shapes.Polyline { Stroke = System.Windows.Media.Brushes.Red, - StrokeThickness = 1, + StrokeThickness = lineThickness, IsHitTestVisible = false }; canvas.Children.Add(_profileCurve); @@ -393,6 +405,64 @@ namespace XplorePlane.Views SetStatus($"行灰度分布 | Y={row} | 均值={rowPixels.Select(b => (double)b).Average():F1} | 最大={rowPixels.Max()} | 最小={rowPixels.Min()}"); } + /// + /// 图像变化时重绘线灰度(移除旧元素,重新创建) + /// + private void RedrawLineProfile() + { + var canvas = FindChildByName(RoiCanvas, "mainCanvas"); + if (canvas == null) return; + + // 移除旧元素 + if (_profileRefLine != null) { canvas.Children.Remove(_profileRefLine); _profileRefLine = null; } + if (_profileRefLineVisible != null) { canvas.Children.Remove(_profileRefLineVisible); _profileRefLineVisible = null; } + if (_profileCurve != null) { canvas.Children.Remove(_profileCurve); _profileCurve = null; } + + // 重新计算参考线位置(保持相对比例或重置到中间) + _profileLineY = RoiCanvas.CanvasHeight / 2; + + // 根据新图像分辨率自适应线条粗细 + double maxDim = Math.Max(RoiCanvas.CanvasWidth, RoiCanvas.CanvasHeight); + double lineThickness = Math.Max(1, Math.Round(maxDim / 1000.0)); + + _profileRefLine = new System.Windows.Shapes.Line + { + X1 = 0, + Y1 = _profileLineY, + X2 = RoiCanvas.CanvasWidth, + Y2 = _profileLineY, + Stroke = System.Windows.Media.Brushes.Transparent, + StrokeThickness = lineThickness + 6, + IsHitTestVisible = true, + Cursor = System.Windows.Input.Cursors.SizeNS + }; + _profileRefLineVisible = new System.Windows.Shapes.Line + { + X1 = 0, + Y1 = _profileLineY, + X2 = RoiCanvas.CanvasWidth, + Y2 = _profileLineY, + Stroke = System.Windows.Media.Brushes.Red, + StrokeThickness = lineThickness, + IsHitTestVisible = false + }; + _profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown; + _profileRefLine.MouseMove += ProfileLine_MouseMove; + _profileRefLine.MouseLeftButtonUp += ProfileLine_MouseUp; + canvas.Children.Add(_profileRefLineVisible); + canvas.Children.Add(_profileRefLine); + + _profileCurve = new System.Windows.Shapes.Polyline + { + Stroke = System.Windows.Media.Brushes.Red, + StrokeThickness = lineThickness, + IsHitTestVisible = false + }; + canvas.Children.Add(_profileCurve); + + UpdateLineProfile(); + } + #endregion private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) From 82b7c3214743868059bce04302e9ec29642d2e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Tue, 26 May 2026 11:35:25 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E6=B5=8B=E9=87=8F=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=BB=84=E7=BA=BF=E5=AE=BD=E6=A0=B9=E6=8D=AE=E5=9B=BE=E5=83=8F?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87=E8=87=AA=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/PolygonRoiCanvas.xaml.cs | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs index e046738..ea2f831 100644 --- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs +++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs @@ -461,6 +461,16 @@ namespace XP.ImageProcessing.RoiControl.Controls #region Measurement + /// + /// 根据画布尺寸计算自适应线宽 + /// + private double GetAdaptiveThickness(double baseThickness = 1.0) + { + double maxDim = Math.Max(CanvasWidth, CanvasHeight); + double scale = Math.Max(1, Math.Round(maxDim / 1000.0)); + return baseThickness * scale; + } + public static readonly DependencyProperty CurrentMeasureModeProperty = DependencyProperty.Register(nameof(CurrentMeasureMode), typeof(Models.MeasureMode), typeof(PolygonRoiCanvas), new PropertyMetadata(Models.MeasureMode.None, OnMeasureModeChanged)); @@ -759,12 +769,12 @@ namespace XP.ImageProcessing.RoiControl.Controls private Models.MeasureGroup CreatePPGroup(Point p1, Point p2) { var g = new Models.MeasureGroup { P1 = p1, P2 = p2 }; - g.Line = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; + g.Line = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false }; // 使用垂直线段代替圆点 - g.PerpLine1 = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = true, Cursor = Cursors.Hand }; - g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = true, Cursor = Cursors.Hand }; + g.PerpLine1 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand }; + g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand }; // 保留圆点以兼容拖拽逻辑(但设为不可见) g.Dot1 = CreateMDot(Brushes.Red); @@ -866,7 +876,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _ptlTempDot2 = CreateMDot(Brushes.Lime); _measureOverlay.Children.Add(_ptlTempDot2); SetDotPos(_ptlTempDot2, pos); - _ptlTempLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false, + _ptlTempLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false, X1 = _ptlTempL1.Value.X, Y1 = _ptlTempL1.Value.Y, X2 = pos.X, Y2 = pos.Y }; _measureOverlay.Children.Add(_ptlTempLine); RaiseMeasureStatusChanged($"点线距 - 直线已定义,请点击测量点"); @@ -892,10 +902,10 @@ namespace XP.ImageProcessing.RoiControl.Controls private Models.PointToLineGroup CreatePTLGroup(Point l1, Point l2, Point p) { var g = new Models.PointToLineGroup { L1 = l1, L2 = l2, P = p }; - g.MainLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; - g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false, + g.MainLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; + g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false, StrokeDashArray = new DoubleCollection { 4, 2 }, Visibility = Visibility.Collapsed }; - g.PerpLine = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = false, + g.PerpLine = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false, StrokeDashArray = new DoubleCollection { 4, 2 } }; g.FootDot = new Ellipse { Width = 2, Height = 2, Fill = Brushes.Cyan, Stroke = Brushes.White, StrokeThickness = 0.5, IsHitTestVisible = false }; g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false }; @@ -929,7 +939,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _elfTempLine = new Line { Stroke = Brushes.Cyan, - StrokeThickness = 1, + StrokeThickness = GetAdaptiveThickness(), StrokeDashArray = new DoubleCollection { 4, 2 }, IsHitTestVisible = false, X1 = _elfTempStart.Value.X, @@ -972,7 +982,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _angleTempADot = CreateMDot(Brushes.Orange); _measureOverlay.Children.Add(_angleTempADot); SetDotPos(_angleTempADot, pos); - _angleTempLineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false, + _angleTempLineA = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false, X1 = _angleTempV.Value.X, Y1 = _angleTempV.Value.Y, X2 = pos.X, Y2 = pos.Y }; _measureOverlay.Children.Add(_angleTempLineA); RaiseMeasureStatusChanged($"角度测量 - 射线A已定义,请点击射线端点B"); @@ -998,9 +1008,9 @@ namespace XP.ImageProcessing.RoiControl.Controls private Models.AngleGroup CreateAngleGroup(Point v, Point a, Point b) { var g = new Models.AngleGroup { V = v, A = a, B = b }; - g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; - g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; - g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = 1.5, IsHitTestVisible = false }; + g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; + g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; + g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(1.5), IsHitTestVisible = false }; g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false }; g.DotV = CreateMDot(Brushes.Red); g.DotA = CreateMDot(Brushes.Orange); @@ -1041,8 +1051,8 @@ namespace XP.ImageProcessing.RoiControl.Controls g.PathE3 = CreateEllipsePath(Brushes.Yellow, true); g.PathE4 = CreateEllipsePath(Brushes.Lime, false); - g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = 1, IsHitTestVisible = false }; - g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false }; + g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; + g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false }; g.Label = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, Cursor = Cursors.Hand }; g.Label.SetValue(ContextMenuService.IsEnabledProperty, false); @@ -1079,9 +1089,9 @@ namespace XP.ImageProcessing.RoiControl.Controls return h; } - private static Path CreateEllipsePath(Brush stroke, bool dashed) + private Path CreateEllipsePath(Brush stroke, bool dashed) { - var p = new Path { Stroke = stroke, StrokeThickness = 1.5, IsHitTestVisible = false }; + var p = new Path { Stroke = stroke, StrokeThickness = GetAdaptiveThickness(1.5), IsHitTestVisible = false }; if (dashed) p.StrokeDashArray = new DoubleCollection { 4, 2 }; return p; } @@ -1165,7 +1175,7 @@ namespace XP.ImageProcessing.RoiControl.Controls c.Shape = new Ellipse { Stroke = isBall ? Brushes.Lime : Brushes.Orange, - StrokeThickness = isBall ? 2 : 1.5, + StrokeThickness = isBall ? GetAdaptiveThickness(2) : GetAdaptiveThickness(1.5), Fill = Brushes.Transparent, IsHitTestVisible = false }; @@ -1553,7 +1563,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _bubbleRoiRect = new Rectangle { Stroke = Brushes.Red, - StrokeThickness = 1, + StrokeThickness = GetAdaptiveThickness(), Fill = Brushes.Transparent, Visibility = Visibility.Collapsed, IsHitTestVisible = false @@ -1610,7 +1620,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _bubbleCircleShape = new Ellipse { Stroke = Brushes.Red, - StrokeThickness = 1, + StrokeThickness = GetAdaptiveThickness(), Fill = Brushes.Transparent, Visibility = Visibility.Collapsed, IsHitTestVisible = false @@ -1639,7 +1649,7 @@ namespace XP.ImageProcessing.RoiControl.Controls _bubblePolyShape = new Polygon { Stroke = Brushes.Red, - StrokeThickness = 1, + StrokeThickness = GetAdaptiveThickness(), Fill = Brushes.Transparent, IsHitTestVisible = false }; From 030433cc922526c0e9d06d5ad4182de06cd18714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Tue, 26 May 2026 13:28:52 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DLogger=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E5=86=99CLAHE=E7=AE=97=E6=B3=95=EF=BC=8C=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E9=81=8D=E5=8E=86=E6=94=B9=E7=94=A8unsafe=E6=8C=87?= =?UTF-8?q?=E9=92=88=E5=8A=A0=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../图像变换/ThresholdProcessor.cs | 25 ++--- .../图像增强/ContrastProcessor.cs | 94 +++++++++++++++---- .../检测分析/QfnLeadPadVoidProcessor.cs | 46 ++++++--- .../检测分析/VoidMeasurementProcessor.cs | 26 +++-- .../滤波处理/GaussianBlurProcessor.cs | 2 +- 5 files changed, 140 insertions(+), 53 deletions(-) diff --git a/XP.ImageProcessing.Processors/图像变换/ThresholdProcessor.cs b/XP.ImageProcessing.Processors/图像变换/ThresholdProcessor.cs index f170dff..239552c 100644 --- a/XP.ImageProcessing.Processors/图像变换/ThresholdProcessor.cs +++ b/XP.ImageProcessing.Processors/图像变换/ThresholdProcessor.cs @@ -73,27 +73,30 @@ public class ThresholdProcessor : ImageProcessorBase if (useOtsu) { - // 使用Otsu算法 CvInvoke.Threshold(inputImage, result, minThreshold, 255, ThresholdType.Otsu); _logger.Debug("Process: UseOtsu = true"); } else { - // 双阈值分割:介于MinThreshold和MaxThreshold之间的为前景(255),其他为背景(0) - byte[,,] inputData = inputImage.Data; - byte[,,] outputData = result.Data; - int height = inputImage.Height; int width = inputImage.Width; - for (int y = 0; y < height; y++) + unsafe { - for (int x = 0; x < width; x++) + byte* srcPtr = (byte*)inputImage.Mat.DataPointer; + byte* dstPtr = (byte*)result.Mat.DataPointer; + int srcStep = inputImage.Mat.Step; + int dstStep = result.Mat.Step; + + for (int y = 0; y < height; y++) { - byte pixelValue = inputData[y, x, 0]; - outputData[y, x, 0] = (pixelValue >= minThreshold && pixelValue <= maxThreshold) - ? (byte)255 - : (byte)0; + byte* srcRow = srcPtr + y * srcStep; + byte* dstRow = dstPtr + y * dstStep; + for (int x = 0; x < width; x++) + { + byte val = srcRow[x]; + dstRow[x] = (val >= minThreshold && val <= maxThreshold) ? (byte)255 : (byte)0; + } } } diff --git a/XP.ImageProcessing.Processors/图像增强/ContrastProcessor.cs b/XP.ImageProcessing.Processors/图像增强/ContrastProcessor.cs index d7219e8..5328eab 100644 --- a/XP.ImageProcessing.Processors/图像增强/ContrastProcessor.cs +++ b/XP.ImageProcessing.Processors/图像增强/ContrastProcessor.cs @@ -135,38 +135,94 @@ public class ContrastProcessor : ImageProcessorBase int tileSize = 8; int width = inputImage.Width; int height = inputImage.Height; + byte[,,] srcData = inputImage.Data; + // 计算分块数 int tilesX = (width + tileSize - 1) / tileSize; int tilesY = (height + tileSize - 1) / tileSize; + int actualTileW = (width + tilesX - 1) / tilesX; + int actualTileH = (height + tilesY - 1) / tilesY; - var result = new Image(width, height); - + // 为每个 tile 计算带 clip limit 的均衡化映射表 + var luts = new byte[tilesY, tilesX, 256]; for (int ty = 0; ty < tilesY; ty++) { for (int tx = 0; tx < tilesX; tx++) { - int x = tx * tileSize; - int y = ty * tileSize; - int w = Math.Min(tileSize, width - x); - int h = Math.Min(tileSize, height - y); + int x0 = tx * actualTileW; + int y0 = ty * actualTileH; + int x1 = Math.Min(x0 + actualTileW, width); + int y1 = Math.Min(y0 + actualTileH, height); + int tilePixels = (x1 - x0) * (y1 - y0); - var roi = new System.Drawing.Rectangle(x, y, w, h); - inputImage.ROI = roi; - var tile = inputImage.Copy(); - inputImage.ROI = System.Drawing.Rectangle.Empty; + // 构建直方图 + var hist = new int[256]; + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) + hist[srcData[y, x, 0]]++; - var equalizedTile = new Image(tile.Size); - CvInvoke.EqualizeHist(tile, equalizedTile); + // Clip limit 裁剪并重新分配 + int clipThreshold = (int)(clipLimit * tilePixels / 256); + if (clipThreshold > 0) + { + int excess = 0; + for (int i = 0; i < 256; i++) + { + if (hist[i] > clipThreshold) + { + excess += hist[i] - clipThreshold; + hist[i] = clipThreshold; + } + } + int avgInc = excess / 256; + int remainder = excess - avgInc * 256; + for (int i = 0; i < 256; i++) + hist[i] += avgInc + (i < remainder ? 1 : 0); + } - result.ROI = roi; - equalizedTile.CopyTo(result); - result.ROI = System.Drawing.Rectangle.Empty; - - tile.Dispose(); - equalizedTile.Dispose(); + // 构建 CDF 映射表 + int sum = 0; + for (int i = 0; i < 256; i++) + { + sum += hist[i]; + luts[ty, tx, i] = (byte)Math.Clamp(sum * 255 / tilePixels, 0, 255); + } } } - _logger.Debug("ApplyCLAHE"); + + // 双线性插值生成结果 + var result = new Image(width, height); + byte[,,] dstData = result.Data; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + // 计算当前像素所在 tile 的中心坐标 + double fx = (double)x / actualTileW - 0.5; + double fy = (double)y / actualTileH - 0.5; + int tx0 = Math.Max(0, (int)fx); + int ty0 = Math.Max(0, (int)fy); + int tx1 = Math.Min(tx0 + 1, tilesX - 1); + int ty1 = Math.Min(ty0 + 1, tilesY - 1); + double ax = fx - tx0; + double ay = fy - ty0; + ax = Math.Clamp(ax, 0, 1); + ay = Math.Clamp(ay, 0, 1); + + byte val = srcData[y, x, 0]; + double v00 = luts[ty0, tx0, val]; + double v10 = luts[ty0, tx1, val]; + double v01 = luts[ty1, tx0, val]; + double v11 = luts[ty1, tx1, val]; + + double interpolated = v00 * (1 - ax) * (1 - ay) + v10 * ax * (1 - ay) + + v01 * (1 - ax) * ay + v11 * ax * ay; + dstData[y, x, 0] = (byte)Math.Clamp((int)(interpolated + 0.5), 0, 255); + } + } + + _logger.Debug("ApplyCLAHE: ClipLimit={ClipLimit}, TileSize={TileSize}", clipLimit, tileSize); return result; } } \ No newline at end of file diff --git a/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs b/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs index 5977eae..f309525 100644 --- a/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs +++ b/XP.ImageProcessing.Processors/检测分析/QfnLeadPadVoidProcessor.cs @@ -291,15 +291,23 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase // 双阈值分割(X-Ray正片:焊点=暗区域,灰度在[threshLow, threshHigh]范围内判为焊点) var binary = new Image(w, h); - byte[,,] srcData = blurred.Data; - byte[,,] dstData = binary.Data; - for (int y = 0; y < h; y++) + unsafe { - for (int x = 0; x < w; x++) + byte* srcPtr = (byte*)blurred.Mat.DataPointer; + byte* dstPtr = (byte*)binary.Mat.DataPointer; + int srcStep = blurred.Mat.Step; + int dstStep = binary.Mat.Step; + + for (int y = 0; y < h; y++) { - byte val = srcData[y, x, 0]; - dstData[y, x, 0] = (val >= threshLow && val <= threshHigh) ? (byte)255 : (byte)0; + byte* srcRow = srcPtr + y * srcStep; + byte* dstRow = dstPtr + y * dstStep; + for (int x = 0; x < w; x++) + { + byte val = srcRow[x]; + dstRow[x] = (val >= threshLow && val <= threshHigh) ? (byte)255 : (byte)0; + } } } @@ -410,18 +418,28 @@ public class QfnLeadPadVoidProcessor : ImageProcessorBase // 双阈值分割(正片模式:空洞=亮区域,灰度在[voidThreshLow, voidThreshHigh]范围内判为空洞) var voidImg = new Image(w, h); - byte[,,] srcData = input.Data; - byte[,,] dstData = voidImg.Data; - byte[,,] maskData = mask.Data; - for (int y = 0; y < h; y++) + unsafe { - for (int x = 0; x < w; x++) + byte* srcPtr = (byte*)input.Mat.DataPointer; + byte* dstPtr = (byte*)voidImg.Mat.DataPointer; + byte* mskPtr = (byte*)mask.Mat.DataPointer; + int srcStep = input.Mat.Step; + int dstStep = voidImg.Mat.Step; + int mskStep = mask.Mat.Step; + + for (int y = 0; y < h; y++) { - if (maskData[y, x, 0] > 0) + byte* srcRow = srcPtr + y * srcStep; + byte* dstRow = dstPtr + y * dstStep; + byte* mskRow = mskPtr + y * mskStep; + for (int x = 0; x < w; x++) { - byte val = srcData[y, x, 0]; - dstData[y, x, 0] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0; + if (mskRow[x] > 0) + { + byte val = srcRow[x]; + dstRow[x] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0; + } } } } diff --git a/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs b/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs index b63d591..a5ecc82 100644 --- a/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs +++ b/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs @@ -125,18 +125,28 @@ public class VoidMeasurementProcessor : ImageProcessorBase // ── 双阈值分割提取气泡(亮区域) ── var voidImg = new Image(w, h); - byte[,,] srcData = blurred.Data; - byte[,,] dstData = voidImg.Data; - byte[,,] maskData = roiMask.Data; - for (int y = 0; y < h; y++) + unsafe { - for (int x = 0; x < w; x++) + byte* srcPtr = (byte*)blurred.Mat.DataPointer; + byte* dstPtr = (byte*)voidImg.Mat.DataPointer; + byte* mskPtr = (byte*)roiMask.Mat.DataPointer; + int srcStep = blurred.Mat.Step; + int dstStep = voidImg.Mat.Step; + int mskStep = roiMask.Mat.Step; + + for (int y = 0; y < h; y++) { - if (maskData[y, x, 0] > 0) + byte* srcRow = srcPtr + y * srcStep; + byte* dstRow = dstPtr + y * dstStep; + byte* mskRow = mskPtr + y * mskStep; + for (int x = 0; x < w; x++) { - byte val = srcData[y, x, 0]; - dstData[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0; + if (mskRow[x] > 0) + { + byte val = srcRow[x]; + dstRow[x] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0; + } } } } diff --git a/XP.ImageProcessing.Processors/滤波处理/GaussianBlurProcessor.cs b/XP.ImageProcessing.Processors/滤波处理/GaussianBlurProcessor.cs index ff34434..0ff8bd2 100644 --- a/XP.ImageProcessing.Processors/滤波处理/GaussianBlurProcessor.cs +++ b/XP.ImageProcessing.Processors/滤波处理/GaussianBlurProcessor.cs @@ -23,7 +23,7 @@ namespace XP.ImageProcessing.Processors; /// public class GaussianBlurProcessor : ImageProcessorBase { - private static readonly ILogger _logger = Log.ForContext(); + private static readonly ILogger _logger = Log.ForContext(); public GaussianBlurProcessor() { From bc626a0ca84d70350da0b7e9a01d1c3589d740b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Wed, 27 May 2026 09:22:44 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E5=9D=90=E6=A0=87=E6=A0=87=E5=AE=9A?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=94=B9=E9=80=A0=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=9C=8D=E5=8A=A1=E6=8E=A5=E5=8F=A3=E5=8F=8A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=A0=87=E5=AE=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Calibration/ICalibrationCaptureService.cs | 73 ++++++ .../ViewModels/CalibrationViewModel.cs | 190 +++++++++++++- .../NavigationCalibrationCaptureService.cs | 245 ++++++++++++++++++ XplorePlane/ViewModels/Main/MainViewModel.cs | 18 +- 4 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 XP.Camera/Calibration/ICalibrationCaptureService.cs create mode 100644 XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs diff --git a/XP.Camera/Calibration/ICalibrationCaptureService.cs b/XP.Camera/Calibration/ICalibrationCaptureService.cs new file mode 100644 index 0000000..c60ca15 --- /dev/null +++ b/XP.Camera/Calibration/ICalibrationCaptureService.cs @@ -0,0 +1,73 @@ +using System.Drawing; +using System.Windows.Media.Imaging; + +namespace XP.Camera.Calibration; + +/// +/// 标定采集服务接口 +/// 提供"一键采集"能力:读取编码器坐标 + 拍图 + 识别标记中心 +/// +public interface ICalibrationCaptureService +{ + /// 是否可用(相机已连接、运动系统就绪) + bool IsAvailable { get; } + + /// + /// 采集当前标定点 + /// + /// 采集结果,失败时返回 null + CaptureResult? CaptureCurrentPoint(); + + /// + /// 获取当前导航相机图像 + /// + BitmapSource? CaptureImage(); + + /// + /// 启动实时预览(将相机实时画面推送到 LiveImageUpdated 事件) + /// + void StartLivePreview(); + + /// + /// 停止实时预览 + /// + void StopLivePreview(); + + /// + /// 实时画面更新事件 + /// + event EventHandler? LiveImageUpdated; +} + +/// +/// 实时画面事件参数 +/// +public class LiveImageEventArgs : EventArgs +{ + public BitmapSource Image { get; } + public LiveImageEventArgs(BitmapSource image) => Image = image; +} + +/// +/// 单次采集结果 +/// +public class CaptureResult +{ + /// 标记中心像素坐标 X(亚像素) + public double PixelX { get; set; } + + /// 标记中心像素坐标 Y(亚像素) + public double PixelY { get; set; } + + /// 平台编码器坐标 X (mm) + public double WorldX { get; set; } + + /// 平台编码器坐标 Y (mm) + public double WorldY { get; set; } + + /// 采集的图像 + public BitmapSource? Image { get; set; } + + /// 检测到的标记轮廓点集 + public System.Drawing.Point[]? ContourPoints { get; set; } +} diff --git a/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs index 90c7acd..404b0f9 100644 --- a/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs +++ b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs @@ -11,16 +11,20 @@ namespace XP.Camera.Calibration.ViewModels; public class CalibrationViewModel : BindableBase { private readonly ICalibrationDialogService _dialogService; + private readonly ICalibrationCaptureService? _captureService; private readonly CalibrationProcessor _calibrator = new(); private Image? _currentImage; private static readonly ILogger _logger = Log.ForContext(); private BitmapSource? _imageSource; + private BitmapSource? _frozenImage; private string _statusText = Res.CalibrationStatusReady; private bool _showWorldCoordinates; + private bool _isLiveView = true; - public CalibrationViewModel(ICalibrationDialogService dialogService) + public CalibrationViewModel(ICalibrationDialogService dialogService, ICalibrationCaptureService? captureService = null) { _dialogService = dialogService; + _captureService = captureService; CalibrationPoints = new ObservableCollection(); LoadImageCommand = new DelegateCommand(LoadImage); @@ -29,6 +33,16 @@ public class CalibrationViewModel : BindableBase .ObservesProperty(() => CalibrationPoints.Count); SaveCalibrationCommand = new DelegateCommand(SaveCalibration); LoadCalibrationCommand = new DelegateCommand(LoadCalibration); + CapturePointCommand = new DelegateCommand(CapturePoint, CanCapturePoint); + DeleteSelectedPointCommand = new DelegateCommand(DeleteSelectedPoint, () => SelectedPoint != null) + .ObservesProperty(() => SelectedPoint); + + // 启动实时预览 + if (_captureService != null) + { + _captureService.LiveImageUpdated += OnLiveImageUpdated; + _captureService.StartLivePreview(); + } } public ObservableCollection CalibrationPoints { get; } @@ -39,6 +53,14 @@ public class CalibrationViewModel : BindableBase set => SetProperty(ref _imageSource, value); } + private BitmapSource? _overlayImage; + /// 叠加层图像(显示检测到的轮廓和中心点) + public BitmapSource? OverlayImage + { + get => _overlayImage; + set => SetProperty(ref _overlayImage, value); + } + public string StatusText { get => _statusText; @@ -51,11 +73,48 @@ public class CalibrationViewModel : BindableBase set => SetProperty(ref _showWorldCoordinates, value); } + public bool IsLiveView + { + get => _isLiveView; + set + { + if (SetProperty(ref _isLiveView, value)) + { + RaisePropertyChanged(nameof(LiveViewButtonText)); + if (value) + { + // 切回实时:恢复实时预览 + _captureService?.StartLivePreview(); + } + else + { + // 切到当前:冻结当前帧 + _frozenImage = _imageSource; + _captureService?.StopLivePreview(); + } + } + } + } + + public string LiveViewButtonText => _isLiveView ? "⏸ 冻结" : "▶ 实时"; + public DelegateCommand LoadImageCommand { get; } public DelegateCommand LoadCsvCommand { get; } public DelegateCommand CalibrateCommand { get; } public DelegateCommand SaveCalibrationCommand { get; } public DelegateCommand LoadCalibrationCommand { get; } + public DelegateCommand CapturePointCommand { get; } + public DelegateCommand DeleteSelectedPointCommand { get; } + + /// 是否支持采集模式(有采集服务注入) + public bool IsCaptureAvailable => _captureService?.IsAvailable == true; + + private CalibrationProcessor.CalibrationPoint? _selectedPoint; + public CalibrationProcessor.CalibrationPoint? SelectedPoint + { + get => _selectedPoint; + set => SetProperty(ref _selectedPoint, value); + } private void LoadImage() { @@ -130,6 +189,59 @@ public class CalibrationViewModel : BindableBase } } + private bool CanCapturePoint() => _captureService?.IsAvailable == true; + + private void CapturePoint() + { + if (_captureService == null) return; + + try + { + var result = _captureService.CaptureCurrentPoint(); + if (result == null) + { + StatusText = "采集失败:未能识别标记点,请确认标记在视野内"; + return; + } + + CalibrationPoints.Add(new CalibrationProcessor.CalibrationPoint + { + PixelX = result.PixelX, + PixelY = result.PixelY, + WorldX = result.WorldX, + WorldY = result.WorldY + }); + + // 更新图像显示 + if (result.Image != null) + { + ImageSource = result.Image; + } + + // 绘制检测结果叠加层(轮廓 + 中心点) + DrawDetectionOverlay(result); + + StatusText = $"已采集第 {CalibrationPoints.Count} 个点: 像素({result.PixelX:F1}, {result.PixelY:F1}) → 物理({result.WorldX:F3}, {result.WorldY:F3})"; + _logger.Information("标定点采集: Pixel=({PixelX:F1}, {PixelY:F1}), World=({WorldX:F3}, {WorldY:F3})", + result.PixelX, result.PixelY, result.WorldX, result.WorldY); + } + catch (Exception ex) + { + StatusText = $"采集异常: {ex.Message}"; + _logger.Error(ex, "标定点采集失败"); + } + } + + private void DeleteSelectedPoint() + { + if (SelectedPoint != null && CalibrationPoints.Contains(SelectedPoint)) + { + CalibrationPoints.Remove(SelectedPoint); + SelectedPoint = null; + StatusText = $"已删除,剩余 {CalibrationPoints.Count} 个标定点"; + } + } + public PointF ConvertPixelToWorld(PointF pixel) => _calibrator.PixelToWorld(pixel); public Image? CurrentImage => _currentImage; @@ -138,6 +250,82 @@ public class CalibrationViewModel : BindableBase private void RaiseEvent(EventHandler? handler) => handler?.Invoke(this, EventArgs.Empty); + private void OnLiveImageUpdated(object? sender, LiveImageEventArgs e) + { + if (!_isLiveView) return; + System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() => + { + ImageSource = e.Image; + }); + } + + /// + /// 绘制检测结果叠加层(轮廓 + 中心十字 + 坐标文字) + /// + private void DrawDetectionOverlay(CaptureResult result) + { + if (result.Image == null) return; + + int w = result.Image.PixelWidth; + int h = result.Image.PixelHeight; + + // 创建透明叠加层 + var visual = new System.Windows.Media.DrawingVisual(); + using (var dc = visual.RenderOpen()) + { + // 绘制轮廓 + if (result.ContourPoints != null && result.ContourPoints.Length > 2) + { + var pen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Lime, 2); + var geometry = new System.Windows.Media.StreamGeometry(); + using (var ctx = geometry.Open()) + { + ctx.BeginFigure(new System.Windows.Point(result.ContourPoints[0].X, result.ContourPoints[0].Y), false, true); + for (int i = 1; i < result.ContourPoints.Length; i++) + ctx.LineTo(new System.Windows.Point(result.ContourPoints[i].X, result.ContourPoints[i].Y), true, false); + } + geometry.Freeze(); + dc.DrawGeometry(null, pen, geometry); + } + + // 绘制中心十字 + double cx = result.PixelX; + double cy = result.PixelY; + double crossSize = Math.Max(10, Math.Max(w, h) / 80.0); + var crossPen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Red, 2); + dc.DrawLine(crossPen, new System.Windows.Point(cx - crossSize, cy), new System.Windows.Point(cx + crossSize, cy)); + dc.DrawLine(crossPen, new System.Windows.Point(cx, cy - crossSize), new System.Windows.Point(cx, cy + crossSize)); + + // 绘制坐标文字 + var text = new System.Windows.Media.FormattedText( + $"({result.PixelX:F1}, {result.PixelY:F1})", + System.Globalization.CultureInfo.CurrentCulture, + System.Windows.FlowDirection.LeftToRight, + new System.Windows.Media.Typeface("Segoe UI"), + Math.Max(12, Math.Max(w, h) / 60.0), + System.Windows.Media.Brushes.Yellow, + 1.0); + dc.DrawText(text, new System.Windows.Point(cx + crossSize + 4, cy - text.Height / 2)); + } + + var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(w, h, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); + rtb.Render(visual); + rtb.Freeze(); + OverlayImage = rtb; + } + + /// + /// 停止实时预览并清理资源(窗口关闭时调用) + /// + public void Cleanup() + { + if (_captureService != null) + { + _captureService.StopLivePreview(); + _captureService.LiveImageUpdated -= OnLiveImageUpdated; + } + } + private static BitmapSource MatToBitmapSource(Mat mat) { using var bitmap = mat.ToBitmap(); diff --git a/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs b/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs new file mode 100644 index 0000000..e7c9fde --- /dev/null +++ b/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs @@ -0,0 +1,245 @@ +using System; +using System.Drawing; +using System.Threading; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using Serilog; +using XP.Camera; +using XP.Camera.Calibration; +using XP.Hardware.MotionControl.Abstractions; +using XP.Hardware.MotionControl.Abstractions.Enums; + +namespace XplorePlane.Services.Calibration; + +/// +/// 导航相机标定采集服务实现 +/// 读取编码器坐标 + 导航相机拍图 + 图像识别标记中心(亚像素) +/// +public class NavigationCalibrationCaptureService : ICalibrationCaptureService +{ + private static readonly ILogger _logger = Log.ForContext(); + private readonly IMotionSystem _motionSystem; + private readonly ICameraController _navCamera; + private BitmapSource? _lastCapturedImage; + private readonly object _captureLock = new(); + private ManualResetEventSlim? _imageReadyEvent; + private bool _livePreviewActive; + + public event EventHandler? LiveImageUpdated; + + public NavigationCalibrationCaptureService(IMotionSystem motionSystem, ICameraController navCamera) + { + _motionSystem = motionSystem; + _navCamera = navCamera; + } + + public bool IsAvailable => _navCamera.IsConnected; + + public void StartLivePreview() + { + if (!IsAvailable || _livePreviewActive) return; + _livePreviewActive = true; + _navCamera.ImageGrabbed += OnLiveImageGrabbed; + + // 如果相机没在采集,启动采集并触发第一帧 + if (!_navCamera.IsGrabbing) + { + _navCamera.StartGrabbing(); + _navCamera.ExecuteSoftwareTrigger(); + } + + _logger.Information("标定实时预览已启动"); + } + + public void StopLivePreview() + { + _livePreviewActive = false; + _navCamera.ImageGrabbed -= OnLiveImageGrabbed; + _logger.Information("标定实时预览已停止"); + } + + private void OnLiveImageGrabbed(object? sender, ImageGrabbedEventArgs e) + { + if (!_livePreviewActive) return; + var bmp = ConvertToBitmapSource(e); + if (bmp != null) + { + _lastLiveImage = bmp; + LiveImageUpdated?.Invoke(this, new LiveImageEventArgs(bmp)); + } + } + + private volatile BitmapSource? _lastLiveImage; + + public CaptureResult? CaptureCurrentPoint() + { + if (!IsAvailable) + { + _logger.Warning("采集失败:导航相机未连接"); + return null; + } + + // 1. 读取编码器坐标 + var xAxis = _motionSystem.GetLinearAxis(AxisId.StageX); + var yAxis = _motionSystem.GetLinearAxis(AxisId.StageY); + xAxis.UpdateStatus(); + yAxis.UpdateStatus(); + double worldX = xAxis.ActualPosition; + double worldY = yAxis.ActualPosition; + + // 2. 导航相机拍图 + var image = CaptureImage(); + if (image == null) + { + _logger.Warning("采集失败:无法获取导航相机图像"); + return null; + } + + // 3. 图像识别标记中心(亚像素) + var grayImage = BitmapSourceToGray(image); + var detection = DetectMarkerCenter(grayImage); + grayImage.Dispose(); + + if (detection == null) + { + _logger.Warning("采集失败:未能识别标记点"); + return null; + } + + _logger.Information("标定点采集成功: Pixel=({Px:F1}, {Py:F1}), World=({Wx:F3}, {Wy:F3})", + detection.Value.Center.X, detection.Value.Center.Y, worldX, worldY); + + return new CaptureResult + { + PixelX = detection.Value.Center.X, + PixelY = detection.Value.Center.Y, + WorldX = worldX, + WorldY = worldY, + Image = image, + ContourPoints = detection.Value.Contour + }; + } + + public BitmapSource? CaptureImage() + { + if (!_navCamera.IsConnected) return null; + + // 如果实时预览在运行,直接使用最新一帧 + if (_livePreviewActive && _lastLiveImage != null) + return _lastLiveImage; + + lock (_captureLock) + { + _lastCapturedImage = null; + _imageReadyEvent = new ManualResetEventSlim(false); + + void OnImageGrabbed(object? sender, ImageGrabbedEventArgs e) + { + _lastCapturedImage = ConvertToBitmapSource(e); + _imageReadyEvent?.Set(); + } + + _navCamera.ImageGrabbed += OnImageGrabbed; + + try + { + if (!_navCamera.IsGrabbing) + _navCamera.StartGrabbing(); + _navCamera.ExecuteSoftwareTrigger(); + + // 等待图像到达(超时 3 秒) + if (!_imageReadyEvent.Wait(3000)) + { + _logger.Warning("导航相机采集超时"); + return null; + } + + return _lastCapturedImage; + } + finally + { + _navCamera.ImageGrabbed -= OnImageGrabbed; + _imageReadyEvent?.Dispose(); + _imageReadyEvent = null; + } + } + } + + /// + /// 检测图像中标记点的中心(亚像素精度) + /// 支持圆点标记:阈值分割 → 轮廓检测 → 面积过滤 → 亚像素质心 + /// + private (PointF Center, Point[] Contour)? DetectMarkerCenter(Image grayImage) + { + int w = grayImage.Width, h = grayImage.Height; + + // 高斯模糊降噪 + using var blurred = new Image(w, h); + CvInvoke.GaussianBlur(grayImage, blurred, new Size(5, 5), 1.0); + + // Otsu 自动阈值二值化 + using var binary = new Image(w, h); + CvInvoke.Threshold(blurred, binary, 0, 255, ThresholdType.Otsu | ThresholdType.Binary); + + // 轮廓检测 + using var contours = new VectorOfVectorOfPoint(); + using var hierarchy = new Mat(); + CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + // 找到面积最大的轮廓(假设标记是视野中最显著的特征) + double maxArea = 0; + int bestIdx = -1; + double minValidArea = w * h * 0.001; // 最小有效面积:图像面积的 0.1% + double maxValidArea = w * h * 0.5; // 最大有效面积:图像面积的 50% + + for (int i = 0; i < contours.Size; i++) + { + double area = CvInvoke.ContourArea(contours[i]); + if (area < minValidArea || area > maxValidArea) continue; + if (area > maxArea) + { + maxArea = area; + bestIdx = i; + } + } + + if (bestIdx < 0) return null; + + // 亚像素质心计算 + var moments = CvInvoke.Moments(contours[bestIdx]); + if (moments.M00 < 1) return null; + + double cx = moments.M10 / moments.M00; + double cy = moments.M01 / moments.M00; + + var contourPoints = contours[bestIdx].ToArray(); + return (new PointF((float)cx, (float)cy), contourPoints); + } + + private static Image BitmapSourceToGray(BitmapSource bmp) + { + var converted = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0); + int w = converted.PixelWidth, h = converted.PixelHeight; + int stride = w * 4; + var pixels = new byte[stride * h]; + converted.CopyPixels(pixels, stride, 0); + var gray = new Image(w, h); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int idx = y * stride + x * 4; + gray.Data[y, x, 0] = (byte)(pixels[idx + 2] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx] * 0.114); + } + return gray; + } + + private static BitmapSource? ConvertToBitmapSource(ImageGrabbedEventArgs e) + { + if (e.Width <= 0 || e.Height <= 0 || e.PixelData == null) return null; + return PixelConverter.ToBitmapSource(e.PixelData, e.Width, e.Height, e.PixelFormat); + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index cb537a3..a653b38 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -676,14 +676,28 @@ namespace XplorePlane.ViewModels }; var calibrationDialogService = new XP.Camera.Calibration.DefaultCalibrationDialogService(); - var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService); + + // 尝试创建采集服务(需要运动系统和导航相机) + XP.Camera.Calibration.ICalibrationCaptureService? captureService = null; + try + { + var motionSystem = Prism.Ioc.ContainerLocator.Current?.Resolve(); + var navCamera = Prism.Ioc.ContainerLocator.Current?.Resolve(); + if (motionSystem != null && navCamera != null) + captureService = new Services.Calibration.NavigationCalibrationCaptureService(motionSystem, navCamera); + } + catch { /* 采集服务不可用时降级为手动模式 */ } + + var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService, captureService); var calibrationControl = new XP.Camera.Calibration.Controls.CalibrationControl { DataContext = calibrationViewModel }; calibrationWindow.Content = calibrationControl; - calibrationWindow.ShowDialog(); + calibrationWindow.Closed += (s, e) => calibrationViewModel.Cleanup(); + calibrationWindow.Owner = System.Windows.Application.Current.MainWindow; + calibrationWindow.Show(); } private void ExecuteOpenSettings() From b3d39c34926f20679fbe94143c650c59228f62b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Wed, 27 May 2026 09:22:56 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E6=A0=87=E5=AE=9A=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E6=98=BE=E7=A4=BA=E6=8E=A7=E4=BB=B6=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=B8=BAPolygonRoiCanvas=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=97=A7ImageCanvasControl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controls/CalibrationControl.xaml | 45 +++- .../Controls/CalibrationControl.xaml.cs | 91 ++----- .../ChessboardCalibrationControl.xaml | 4 +- .../ChessboardCalibrationControl.xaml.cs | 33 --- .../Controls/ImageCanvasControl.xaml | 33 --- .../Controls/ImageCanvasControl.xaml.cs | 229 ------------------ XP.Camera/XP.Camera.csproj | 1 + 7 files changed, 73 insertions(+), 363 deletions(-) delete mode 100644 XP.Camera/Calibration/Controls/ImageCanvasControl.xaml delete mode 100644 XP.Camera/Calibration/Controls/ImageCanvasControl.xaml.cs diff --git a/XP.Camera/Calibration/Controls/CalibrationControl.xaml b/XP.Camera/Calibration/Controls/CalibrationControl.xaml index 9c12e48..a5b1d88 100644 --- a/XP.Camera/Calibration/Controls/CalibrationControl.xaml +++ b/XP.Camera/Calibration/Controls/CalibrationControl.xaml @@ -5,6 +5,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:cal="clr-namespace:XP.Camera.Calibration" xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls" + xmlns:roi="clr-namespace:XP.ImageProcessing.RoiControl.Controls;assembly=XP.ImageProcessing.RoiControl" mc:Ignorable="d" d:DesignHeight="850" d:DesignWidth="1400"> @@ -88,6 +89,20 @@ + +