diff --git a/XP.Common/Resources/Resources.en-US.resx b/XP.Common/Resources/Resources.en-US.resx index 3154237..5885aaf 100644 --- a/XP.Common/Resources/Resources.en-US.resx +++ b/XP.Common/Resources/Resources.en-US.resx @@ -1887,4 +1887,60 @@ Reprojection error: {1:F4} pixels Image{0}: {1:F4} pixels + + + + Edge Find Line Fit + + + Place calipers along a search line to detect edge points and fit a line (supports Least Squares and RANSAC) + + + Caliper Count + + + Number of calipers placed evenly along the search line + + + Caliper Width + + + Search length of each caliper (pixels), perpendicular to the search line + + + Edge Polarity + + + Edge direction: BrightToDark, DarkToBright, or Both + + + Edge Threshold + + + Gradient strength threshold; edges below this value are ignored + + + Smoothing Sigma + + + Gaussian smoothing standard deviation for noise suppression (larger = smoother) + + + Fit Method + + + Line fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers) + + + RANSAC Threshold + + + RANSAC inlier distance threshold (pixels); points closer than this to the line are inliers + + + Line Thickness + + + Drawing thickness for result visualization + \ No newline at end of file diff --git a/XP.Common/Resources/Resources.resx b/XP.Common/Resources/Resources.resx index 7f0cc41..eac5543 100644 --- a/XP.Common/Resources/Resources.resx +++ b/XP.Common/Resources/Resources.resx @@ -1920,4 +1920,60 @@ 图像{0}: {1:F4} 像素 + + + + 边缘查找拟合直线 + + + 沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC) + + + 卡尺数量 + + + 沿搜索线等间距放置的卡尺数量 + + + 卡尺宽度 + + + 每个卡尺的搜索长度(像素),沿垂直于搜索线方向 + + + 边缘极性 + + + 边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向) + + + 边缘阈值 + + + 边缘梯度强度阈值,低于此值的边缘将被忽略 + + + 平滑Sigma + + + 高斯平滑的标准差,用于抑制噪声(越大越平滑) + + + 拟合方法 + + + 直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点) + + + RANSAC阈值 + + + RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点 + + + 线条粗细 + + + 绘制结果的线条粗细 + \ 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 7dc08f9..0d99bfa 100644 --- a/XP.Common/Resources/Resources.zh-CN.resx +++ b/XP.Common/Resources/Resources.zh-CN.resx @@ -1881,4 +1881,60 @@ 图像{0}: {1:F4} 像素 + + + + 边缘查找拟合直线 + + + 沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC) + + + 卡尺数量 + + + 沿搜索线等间距放置的卡尺数量 + + + 卡尺宽度 + + + 每个卡尺的搜索长度(像素),沿垂直于搜索线方向 + + + 边缘极性 + + + 边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向) + + + 边缘阈值 + + + 边缘梯度强度阈值,低于此值的边缘将被忽略 + + + 平滑Sigma + + + 高斯平滑的标准差,用于抑制噪声(越大越平滑) + + + 拟合方法 + + + 直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点) + + + RANSAC阈值 + + + RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点 + + + 线条粗细 + + + 绘制结果的线条粗细 + \ No newline at end of file diff --git a/XP.ImageProcessing.Processors/检测分析/EdgeLineFitProcessor.cs b/XP.ImageProcessing.Processors/检测分析/EdgeLineFitProcessor.cs new file mode 100644 index 0000000..0fad857 --- /dev/null +++ b/XP.ImageProcessing.Processors/检测分析/EdgeLineFitProcessor.cs @@ -0,0 +1,638 @@ +// ============================================================================ +// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. +// 文件名: EdgeLineFitProcessor.cs +// 描述: 边缘查找拟合直线算子 +// 功能: +// - 沿用户定义的搜索线等间距放置多个卡尺(Caliper) +// - 在每个卡尺内沿垂直方向提取灰度投影并求导,定位边缘点 +// - 支持亚像素精度(抛物线插值) +// - 支持边缘极性选择(亮到暗/暗到亮/双向) +// - 使用最小二乘或RANSAC算法拟合直线 +// - 输出拟合直线参数、边缘点、内点/外点、拟合误差 +// 算法: 卡尺边缘检测 + 最小二乘/RANSAC直线拟合 +// 作者: 李伟 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 EdgePointInfo +{ + /// 边缘点坐标(亚像素) + public PointF Position { get; set; } + + /// 边缘强度(梯度绝对值) + public double Strength { get; set; } + + /// 卡尺索引 + public int CaliperIndex { get; set; } + + /// 是否为拟合内点 + public bool IsInlier { get; set; } = true; +} + +/// +/// 直线拟合结果 +/// +public class LineFitResult +{ + /// 拟合是否成功 + public bool Success { get; set; } + + /// 直线方向向量 (vx, vy) + public PointF Direction { get; set; } + + /// 直线上一点 (x0, y0) + public PointF PointOnLine { get; set; } + + /// 直线角度(度,相对于X轴) + public double AngleDegrees { get; set; } + + /// 直线端点1(用于绘制) + public PointF Endpoint1 { get; set; } + + /// 直线端点2(用于绘制) + public PointF Endpoint2 { get; set; } + + /// 所有检测到的边缘点 + public List EdgePoints { get; set; } = new(); + + /// 内点列表 + public List Inliers { get; set; } = new(); + + /// 外点列表 + public List Outliers { get; set; } = new(); + + /// 平均拟合误差(像素) + public double FitError { get; set; } + + /// 有效边缘点数 + public int EdgePointCount { get; set; } +} + +/// +/// 边缘查找拟合直线算子 - 使用卡尺法检测边缘点并拟合直线 +/// +public class EdgeLineFitProcessor : ImageProcessorBase +{ + private static readonly ILogger _logger = Log.ForContext(); + private static readonly Random _random = new(); + + public EdgeLineFitProcessor() + { + Name = LocalizationHelper.GetString("EdgeLineFitProcessor_Name"); + Description = LocalizationHelper.GetString("EdgeLineFitProcessor_Description"); + } + + protected override void InitializeParameters() + { + // ── 搜索线起止点(由UI交互控件注入,不可见) ── + Parameters.Add("StartX", new ProcessorParameter( + "StartX", "StartX", typeof(int), 100, null, null, "") { IsVisible = false }); + Parameters.Add("StartY", new ProcessorParameter( + "StartY", "StartY", typeof(int), 200, null, null, "") { IsVisible = false }); + Parameters.Add("EndX", new ProcessorParameter( + "EndX", "EndX", typeof(int), 400, null, null, "") { IsVisible = false }); + Parameters.Add("EndY", new ProcessorParameter( + "EndY", "EndY", typeof(int), 200, null, null, "") { IsVisible = false }); + + // ── 卡尺参数 ── + Parameters.Add("CaliperCount", new ProcessorParameter( + "CaliperCount", + LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperCount"), + typeof(int), 20, 3, 200, + LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperCount_Desc"))); + + Parameters.Add("CaliperWidth", new ProcessorParameter( + "CaliperWidth", + LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperWidth"), + typeof(int), 40, 5, 500, + LocalizationHelper.GetString("EdgeLineFitProcessor_CaliperWidth_Desc"))); + + // ── 边缘检测参数 ── + Parameters.Add("EdgePolarity", new ProcessorParameter( + "EdgePolarity", + LocalizationHelper.GetString("EdgeLineFitProcessor_EdgePolarity"), + typeof(string), "Both", null, null, + LocalizationHelper.GetString("EdgeLineFitProcessor_EdgePolarity_Desc"), + new string[] { "BrightToDark", "DarkToBright", "Both" })); + + Parameters.Add("EdgeThreshold", new ProcessorParameter( + "EdgeThreshold", + LocalizationHelper.GetString("EdgeLineFitProcessor_EdgeThreshold"), + typeof(int), 30, 1, 255, + LocalizationHelper.GetString("EdgeLineFitProcessor_EdgeThreshold_Desc"))); + + Parameters.Add("Sigma", new ProcessorParameter( + "Sigma", + LocalizationHelper.GetString("EdgeLineFitProcessor_Sigma"), + typeof(double), 1.0, 0.1, 10.0, + LocalizationHelper.GetString("EdgeLineFitProcessor_Sigma_Desc"))); + + // ── 拟合参数 ── + Parameters.Add("FitMethod", new ProcessorParameter( + "FitMethod", + LocalizationHelper.GetString("EdgeLineFitProcessor_FitMethod"), + typeof(string), "RANSAC", null, null, + LocalizationHelper.GetString("EdgeLineFitProcessor_FitMethod_Desc"), + new string[] { "LeastSquares", "RANSAC" })); + + Parameters.Add("RansacThreshold", new ProcessorParameter( + "RansacThreshold", + LocalizationHelper.GetString("EdgeLineFitProcessor_RansacThreshold"), + typeof(double), 2.0, 0.5, 20.0, + LocalizationHelper.GetString("EdgeLineFitProcessor_RansacThreshold_Desc"))); + + Parameters.Add("Thickness", new ProcessorParameter( + "Thickness", + LocalizationHelper.GetString("EdgeLineFitProcessor_Thickness"), + typeof(int), 2, 1, 10, + LocalizationHelper.GetString("EdgeLineFitProcessor_Thickness_Desc"))); + } + + public override Image Process(Image inputImage) + { + // 读取参数 + int startX = GetParameter("StartX"); + int startY = GetParameter("StartY"); + int endX = GetParameter("EndX"); + int endY = GetParameter("EndY"); + int caliperCount = GetParameter("CaliperCount"); + int caliperWidth = GetParameter("CaliperWidth"); + string edgePolarity = GetParameter("EdgePolarity"); + int edgeThreshold = GetParameter("EdgeThreshold"); + double sigma = GetParameter("Sigma"); + string fitMethod = GetParameter("FitMethod"); + double ransacThreshold = GetParameter("RansacThreshold"); + int thickness = GetParameter("Thickness"); + + OutputData.Clear(); + + _logger.Debug( + "EdgeLineFit started: Search({StartX},{StartY})->({EndX},{EndY}), Calipers={Count}, Width={Width}, Polarity={Polarity}", + startX, startY, endX, endY, caliperCount, caliperWidth, edgePolarity); + + // 计算搜索线方向和垂直方向 + double searchDx = endX - startX; + double searchDy = endY - startY; + double searchLen = Math.Sqrt(searchDx * searchDx + searchDy * searchDy); + + if (searchLen < 1.0) + { + _logger.Warning("Search line too short, cannot perform edge detection"); + OutputData["LineFitResult"] = new LineFitResult { Success = false }; + return inputImage.Clone(); + } + + // 搜索线单位方向 + double ux = searchDx / searchLen; + double uy = searchDy / searchLen; + + // 垂直于搜索线的方向(卡尺搜索方向) + double perpX = -uy; + double perpY = ux; + + // 沿搜索线等间距放置卡尺 + var edgePoints = new List(); + double step = searchLen / (caliperCount + 1); + + for (int i = 0; i < caliperCount; i++) + { + // 卡尺中心点 + double cx = startX + ux * step * (i + 1); + double cy = startY + uy * step * (i + 1); + + // 在卡尺内沿垂直方向提取灰度剖面 + var edgePoint = FindEdgeInCaliper( + inputImage, cx, cy, perpX, perpY, + caliperWidth, edgePolarity, edgeThreshold, sigma, i); + + if (edgePoint != null) + { + edgePoints.Add(edgePoint); + } + } + + _logger.Debug("Found {Count} edge points from {Total} calipers", edgePoints.Count, caliperCount); + + // 拟合直线 + var result = FitLine(edgePoints, fitMethod, ransacThreshold, inputImage.Size); + + // 存储输出数据 + OutputData["LineFitResult"] = result; + OutputData["EdgePoints"] = edgePoints.Select(p => p.Position).ToArray(); + OutputData["EdgePointCount"] = edgePoints.Count; + OutputData["Thickness"] = thickness; + + if (result.Success) + { + OutputData["FittedLineDirection"] = result.Direction; + OutputData["FittedLinePoint"] = result.PointOnLine; + OutputData["LineAngle"] = result.AngleDegrees; + OutputData["LineEndpoint1"] = result.Endpoint1; + OutputData["LineEndpoint2"] = result.Endpoint2; + OutputData["InlierPoints"] = result.Inliers.ToArray(); + OutputData["OutlierPoints"] = result.Outliers.ToArray(); + OutputData["FitError"] = result.FitError; + + _logger.Information( + "EdgeLineFit completed: Angle={Angle:F2}°, Inliers={Inliers}/{Total}, Error={Error:F3}px", + result.AngleDegrees, result.Inliers.Count, edgePoints.Count, result.FitError); + } + else + { + _logger.Warning("EdgeLineFit failed: insufficient edge points for line fitting"); + } + + // 搜索区域信息(供UI绘制) + OutputData["SearchStart"] = new PointF(startX, startY); + OutputData["SearchEnd"] = new PointF(endX, endY); + OutputData["CaliperWidth"] = caliperWidth; + OutputData["CaliperCount"] = caliperCount; + OutputData["PerpDirection"] = new PointF((float)perpX, (float)perpY); + + return inputImage.Clone(); + } + + /// + /// 在单个卡尺内查找边缘点 + /// + private EdgePointInfo? FindEdgeInCaliper( + Image image, + double centerX, double centerY, + double perpX, double perpY, + int caliperWidth, string polarity, + int threshold, double sigma, int caliperIndex) + { + int halfWidth = caliperWidth / 2; + int profileLength = caliperWidth; + + // 提取灰度剖面 + var profile = new double[profileLength]; + int validCount = 0; + + for (int i = 0; i < profileLength; i++) + { + double offset = i - halfWidth; + double px = centerX + perpX * offset; + double py = centerY + perpY * offset; + + int ix = (int)Math.Round(px); + int iy = (int)Math.Round(py); + + if (ix >= 0 && ix < image.Width && iy >= 0 && iy < image.Height) + { + profile[i] = image.Data[iy, ix, 0]; + validCount++; + } + else + { + profile[i] = 0; + } + } + + if (validCount < profileLength * 0.5) + return null; + + // 高斯平滑 + if (sigma > 0.1) + { + profile = GaussianSmooth1D(profile, sigma); + } + + // 求一阶导数 + var derivative = new double[profileLength]; + for (int i = 1; i < profileLength - 1; i++) + { + derivative[i] = (profile[i + 1] - profile[i - 1]) / 2.0; + } + + // 根据极性查找最强边缘 + int bestIdx = -1; + double bestStrength = 0; + + for (int i = 2; i < profileLength - 2; i++) + { + double strength = derivative[i]; + bool validPolarity = polarity switch + { + "BrightToDark" => strength < 0, // 亮到暗:导数为负 + "DarkToBright" => strength > 0, // 暗到亮:导数为正 + _ => true // Both:任意方向 + }; + + if (!validPolarity) continue; + + double absStrength = Math.Abs(strength); + if (absStrength >= threshold && absStrength > bestStrength) + { + bestStrength = absStrength; + bestIdx = i; + } + } + + if (bestIdx < 0) + return null; + + // 亚像素精度:抛物线插值 + double subPixelOffset = 0; + if (bestIdx > 0 && bestIdx < profileLength - 1) + { + double left = Math.Abs(derivative[bestIdx - 1]); + double center = Math.Abs(derivative[bestIdx]); + double right = Math.Abs(derivative[bestIdx + 1]); + double denom = 2.0 * (2.0 * center - left - right); + if (Math.Abs(denom) > 1e-6) + { + subPixelOffset = (left - right) / denom; + subPixelOffset = Math.Clamp(subPixelOffset, -0.5, 0.5); + } + } + + double edgeOffset = (bestIdx + subPixelOffset) - halfWidth; + float edgeX = (float)(centerX + perpX * edgeOffset); + float edgeY = (float)(centerY + perpY * edgeOffset); + + return new EdgePointInfo + { + Position = new PointF(edgeX, edgeY), + Strength = bestStrength, + CaliperIndex = caliperIndex, + IsInlier = true + }; + } + + /// + /// 一维高斯平滑 + /// + private static double[] GaussianSmooth1D(double[] data, double sigma) + { + int kernelRadius = (int)Math.Ceiling(sigma * 3); + int kernelSize = kernelRadius * 2 + 1; + var kernel = new double[kernelSize]; + double sum = 0; + + for (int i = 0; i < kernelSize; i++) + { + double x = i - kernelRadius; + kernel[i] = Math.Exp(-x * x / (2.0 * sigma * sigma)); + sum += kernel[i]; + } + for (int i = 0; i < kernelSize; i++) + kernel[i] /= sum; + + var result = new double[data.Length]; + for (int i = 0; i < data.Length; i++) + { + double val = 0; + double wSum = 0; + for (int k = 0; k < kernelSize; k++) + { + int idx = i + k - kernelRadius; + if (idx >= 0 && idx < data.Length) + { + val += data[idx] * kernel[k]; + wSum += kernel[k]; + } + } + result[i] = wSum > 0 ? val / wSum : data[i]; + } + return result; + } + + /// + /// 拟合直线 + /// + private LineFitResult FitLine(List edgePoints, string method, + double ransacThreshold, Size imageSize) + { + var result = new LineFitResult(); + + if (edgePoints.Count < 2) + { + result.Success = false; + return result; + } + + if (method == "RANSAC" && edgePoints.Count >= 3) + { + return FitLineRANSAC(edgePoints, ransacThreshold, imageSize); + } + else + { + return FitLineLeastSquares(edgePoints, imageSize); + } + } + + /// + /// 最小二乘直线拟合(使用OpenCV FitLine) + /// + private LineFitResult FitLineLeastSquares(List edgePoints, Size imageSize) + { + var result = new LineFitResult(); + var points = edgePoints.Select(p => p.Position).ToArray(); + + using var pointVector = new VectorOfPointF(points); + using var lineMat = new Mat(); + CvInvoke.FitLine(pointVector, lineMat, DistType.L2, 0, 0.01, 0.01); + var lineParams = new float[4]; + System.Runtime.InteropServices.Marshal.Copy(lineMat.DataPointer, lineParams, 0, 4); + + float vx = lineParams[0], vy = lineParams[1]; + float x0 = lineParams[2], y0 = lineParams[3]; + + result.Success = true; + result.Direction = new PointF(vx, vy); + result.PointOnLine = new PointF(x0, y0); + result.AngleDegrees = Math.Atan2(vy, vx) * 180.0 / Math.PI; + + // 计算端点(延伸到图像边界或搜索范围) + ComputeLineEndpoints(result, points, imageSize); + + // 所有点都是内点 + result.Inliers = points.ToList(); + result.Outliers = new List(); + foreach (var ep in edgePoints) + ep.IsInlier = true; + + // 计算拟合误差 + result.FitError = ComputeFitError(points, vx, vy, x0, y0); + result.EdgePointCount = edgePoints.Count; + result.EdgePoints = edgePoints; + + return result; + } + + /// + /// RANSAC直线拟合 + /// + private LineFitResult FitLineRANSAC(List edgePoints, double threshold, Size imageSize) + { + var result = new LineFitResult(); + var points = edgePoints.Select(p => p.Position).ToArray(); + int n = points.Length; + + // RANSAC参数 + int maxIterations = Math.Min(1000, n * (n - 1) / 2); + int bestInlierCount = 0; + float bestVx = 0, bestVy = 0, bestX0 = 0, bestY0 = 0; + List bestInlierIndices = new(); + + for (int iter = 0; iter < maxIterations; iter++) + { + // 随机选择2个点 + int idx1 = _random.Next(n); + int idx2 = _random.Next(n); + if (idx1 == idx2) continue; + + PointF p1 = points[idx1], p2 = points[idx2]; + float dx = p2.X - p1.X, dy = p2.Y - p1.Y; + float len = (float)Math.Sqrt(dx * dx + dy * dy); + if (len < 1e-6f) continue; + + float vx = dx / len, vy = dy / len; + + // 统计内点 + var inlierIndices = new List(); + for (int i = 0; i < n; i++) + { + double dist = PointToLineDistance(points[i], p1, vx, vy); + if (dist <= threshold) + { + inlierIndices.Add(i); + } + } + + if (inlierIndices.Count > bestInlierCount) + { + bestInlierCount = inlierIndices.Count; + bestInlierIndices = inlierIndices; + + // 用所有内点重新拟合 + var inlierPoints = inlierIndices.Select(i => points[i]).ToArray(); + using var pv = new VectorOfPointF(inlierPoints); + using var lpMat = new Mat(); + CvInvoke.FitLine(pv, lpMat, DistType.L2, 0, 0.01, 0.01); + var lp = new float[4]; + System.Runtime.InteropServices.Marshal.Copy(lpMat.DataPointer, lp, 0, 4); + bestVx = lp[0]; bestVy = lp[1]; bestX0 = lp[2]; bestY0 = lp[3]; + } + + // 如果内点比例已经很高,提前退出 + if (bestInlierCount > n * 0.95) + break; + } + + if (bestInlierCount < 2) + { + result.Success = false; + return result; + } + + result.Success = true; + result.Direction = new PointF(bestVx, bestVy); + result.PointOnLine = new PointF(bestX0, bestY0); + result.AngleDegrees = Math.Atan2(bestVy, bestVx) * 180.0 / Math.PI; + + // 分类内点/外点 + var inliers = new List(); + var outliers = new List(); + var inlierSet = new HashSet(bestInlierIndices); + + for (int i = 0; i < n; i++) + { + if (inlierSet.Contains(i)) + { + inliers.Add(points[i]); + edgePoints[i].IsInlier = true; + } + else + { + outliers.Add(points[i]); + edgePoints[i].IsInlier = false; + } + } + + result.Inliers = inliers; + result.Outliers = outliers; + + // 计算端点 + ComputeLineEndpoints(result, inliers.ToArray(), imageSize); + + // 计算拟合误差(仅内点) + result.FitError = ComputeFitError(inliers.ToArray(), bestVx, bestVy, bestX0, bestY0); + result.EdgePointCount = edgePoints.Count; + result.EdgePoints = edgePoints; + + return result; + } + + /// + /// 计算点到直线的距离 + /// + private static double PointToLineDistance(PointF point, PointF linePoint, float vx, float vy) + { + // 直线法向量 (-vy, vx) + double dx = point.X - linePoint.X; + double dy = point.Y - linePoint.Y; + return Math.Abs(-vy * dx + vx * dy); + } + + /// + /// 计算直线端点(基于边缘点的投影范围) + /// + private static void ComputeLineEndpoints(LineFitResult result, PointF[] points, Size imageSize) + { + float vx = result.Direction.X, vy = result.Direction.Y; + float x0 = result.PointOnLine.X, y0 = result.PointOnLine.Y; + + // 将所有点投影到直线方向上,找最小和最大投影值 + double minT = double.MaxValue, maxT = double.MinValue; + foreach (var p in points) + { + double t = (p.X - x0) * vx + (p.Y - y0) * vy; + if (t < minT) minT = t; + if (t > maxT) maxT = t; + } + + // 稍微延伸一点 + double extend = (maxT - minT) * 0.05; + minT -= extend; + maxT += extend; + + result.Endpoint1 = new PointF( + (float)(x0 + vx * minT), + (float)(y0 + vy * minT)); + result.Endpoint2 = new PointF( + (float)(x0 + vx * maxT), + (float)(y0 + vy * maxT)); + } + + /// + /// 计算平均拟合误差 + /// + private static double ComputeFitError(PointF[] points, float vx, float vy, float x0, float y0) + { + if (points.Length == 0) return 0; + + double totalError = 0; + foreach (var p in points) + { + double dx = p.X - x0; + double dy = p.Y - y0; + double dist = Math.Abs(-vy * dx + vx * dy); + totalError += dist; + } + return totalError / points.Length; + } +} diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs index 8b101e1..0eaf82d 100644 --- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs +++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs @@ -504,6 +504,12 @@ namespace XP.ImageProcessing.RoiControl.Controls private Point? _bgaPendingCenter; // 等待第二次点击定半径 private Ellipse _bgaPendingDot; + // 边缘查找拟合直线临时状态 + private int _elfClickCount; + private Ellipse _elfTempDot1; + private Line _elfTempLine; + private Point? _elfTempStart; + // 气泡测量状态 public enum BubbleSubTool { Roi, RoiCircle, RoiPolygon, Wand, Brush, Eraser } private BubbleSubTool _bubbleTool = BubbleSubTool.Roi; @@ -690,6 +696,8 @@ namespace XP.ImageProcessing.RoiControl.Controls HandleFillRateClick(pos); else if (CurrentMeasureMode == Models.MeasureMode.BgaVoid) HandleBgaVoidClick(pos); + else if (CurrentMeasureMode == Models.MeasureMode.EdgeLineFit) + HandleEdgeLineFitClick(pos); // BubbleMeasure 的点击在 MouseDown/Move/Up 中处理(拖拽画 ROI 和画笔) } @@ -870,6 +878,49 @@ namespace XP.ImageProcessing.RoiControl.Controls return g; } + // ── 边缘查找拟合直线 ── + + private void HandleEdgeLineFitClick(Point pos) + { + _elfClickCount++; + + if (_elfClickCount == 1) + { + _elfTempStart = pos; + _elfTempDot1 = CreateMDot(Brushes.Cyan); + _measureOverlay.Children.Add(_elfTempDot1); + SetDotPos(_elfTempDot1, pos); + RaiseMeasureStatusChanged($"直线拟合 - 搜索线起点: ({pos.X:F0}, {pos.Y:F0}),请点击搜索线终点"); + } + else if (_elfClickCount == 2) + { + // 绘制搜索线 + _elfTempLine = new Line + { + Stroke = Brushes.Cyan, + StrokeThickness = 1, + StrokeDashArray = new DoubleCollection { 4, 2 }, + IsHitTestVisible = false, + X1 = _elfTempStart.Value.X, + Y1 = _elfTempStart.Value.Y, + X2 = pos.X, + Y2 = pos.Y + }; + _measureOverlay.Children.Add(_elfTempLine); + + // 触发完成事件,传递搜索线起止点 + RaiseMeasureCompleted(_elfTempStart.Value, pos, 0, MeasureCount, "EdgeLineFit"); + RaiseMeasureStatusChanged($"直线拟合 - 搜索线已定义: ({_elfTempStart.Value.X:F0},{_elfTempStart.Value.Y:F0}) → ({pos.X:F0},{pos.Y:F0})"); + + // 清理临时状态 + if (_elfTempDot1 != null) _measureOverlay.Children.Remove(_elfTempDot1); + _elfTempDot1 = null; + _elfTempStart = null; + _elfClickCount = 0; + CurrentMeasureMode = Models.MeasureMode.None; + } + } + // ── 角度测量 ── private void HandleAngleClick(Point pos) diff --git a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs index 039b8d1..d726a79 100644 --- a/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs +++ b/XP.ImageProcessing.RoiControl/Models/MeasureMode.cs @@ -8,6 +8,7 @@ namespace XP.ImageProcessing.RoiControl.Models Angle, FillRate, BgaVoid, - BubbleMeasure + BubbleMeasure, + EdgeLineFit } } diff --git a/XplorePlane/Events/MeasurementToolEvent.cs b/XplorePlane/Events/MeasurementToolEvent.cs index 526e189..2253239 100644 --- a/XplorePlane/Events/MeasurementToolEvent.cs +++ b/XplorePlane/Events/MeasurementToolEvent.cs @@ -27,7 +27,8 @@ namespace XplorePlane.Events Angle, ThroughHoleFillRate, BgaVoid, - BubbleMeasure + BubbleMeasure, + EdgeLineFit } /// diff --git a/XplorePlane/ViewModels/ImageProcessing/EdgeLineFitViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/EdgeLineFitViewModel.cs new file mode 100644 index 0000000..b540dc4 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/EdgeLineFitViewModel.cs @@ -0,0 +1,631 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Shapes; +using Emgu.CV; +using Emgu.CV.Structure; +using Prism.Commands; +using Prism.Mvvm; +using XP.ImageProcessing.Processors; +using XP.ImageProcessing.RoiControl.Controls; +using XplorePlane.Services.MainViewport; +using Brushes = System.Windows.Media.Brushes; +using Ellipse = System.Windows.Shapes.Ellipse; +using Point = System.Windows.Point; + +namespace XplorePlane.ViewModels.ImageProcessing +{ + /// + /// 边缘查找拟合直线 ViewModel + /// 支持多次拟合,每次点击"画卡尺"开始一次新的测量,结果累积保留 + /// 关闭面板时保留所有结果,仅清除当前正在编辑的临时卡尺 + /// + public class EdgeLineFitViewModel : BindableBase + { + private readonly IMainViewportService _viewportService; + private PolygonRoiCanvas _canvas; + private Canvas _mainCanvas; + + // 当前正在编辑的搜索线 + private Point _lineStart; + private Point _lineEnd; + private double _halfWidth = 30; + private bool _lineDefined; + + // 当前编辑中的临时可视化(卡尺框+手柄,拟合前可调整) + private readonly List _tempOverlays = new(); + + // 已完成的拟合结果(永久保留在画布上) + // 不由本类管理生命周期,关闭面板后仍保留 + private readonly List _committedOverlays = new(); + + // 手柄位置 + private Point _handleStartPos, _handleEndPos, _handleTopPos, _handleBottomPos; + + // 交互状态 + private enum DragTarget { None, Start, End, Top, Bottom } + private DragTarget _dragging = DragTarget.None; + private bool _isDrawingLine; + private int _drawClickCount; + private int _fitCount; + + private const double HandleSize = 12; + private const double HitRadius = 10; + private static readonly SolidColorBrush CaliperStroke; + private static readonly SolidColorBrush CaliperFill; + private static readonly SolidColorBrush FitLineBrush; + private static readonly SolidColorBrush HandleFill; + + static EdgeLineFitViewModel() + { + CaliperStroke = new SolidColorBrush(Color.FromRgb(0, 255, 0)); + CaliperStroke.Freeze(); + CaliperFill = new SolidColorBrush(Color.FromArgb(20, 0, 255, 0)); + CaliperFill.Freeze(); + FitLineBrush = new SolidColorBrush(Color.FromRgb(30, 144, 255)); + FitLineBrush.Freeze(); + HandleFill = new SolidColorBrush(Color.FromArgb(220, 255, 255, 255)); + HandleFill.Freeze(); + } + + public EdgeLineFitViewModel(IMainViewportService viewportService) + { + _viewportService = viewportService; + FitCommand = new DelegateCommand(ExecuteFit, () => _lineDefined); + ClearAllCommand = new DelegateCommand(ExecuteClearAll); + DrawCaliperCommand = new DelegateCommand(ExecuteDrawCaliper); + } + + // ── 命令 ── + public DelegateCommand FitCommand { get; } + public DelegateCommand ClearAllCommand { get; } + public DelegateCommand DrawCaliperCommand { get; } + + // ── 参数 ── + private int _caliperCount = 20; + public int CaliperCount + { + get => _caliperCount; + set { if (SetProperty(ref _caliperCount, value)) RedrawTemp(); } + } + + private int _displayWidth = 60; + public int DisplayWidth + { + get => _displayWidth; + set + { + if (SetProperty(ref _displayWidth, Math.Max(10, value))) + { + _halfWidth = _displayWidth / 2.0; + RedrawTemp(); + } + } + } + + private string _edgePolarity = "Both"; + public string EdgePolarity + { + get => _edgePolarity; + set { if (SetProperty(ref _edgePolarity, value)) RedrawTemp(); } + } + + private int _edgeThreshold = 20; + public int EdgeThreshold { get => _edgeThreshold; set => SetProperty(ref _edgeThreshold, value); } + + private double _sigma = 1.0; + public double Sigma { get => _sigma; set => SetProperty(ref _sigma, value); } + + private string _fitMethod = "RANSAC"; + public string FitMethod { get => _fitMethod; set => SetProperty(ref _fitMethod, value); } + + private double _ransacThreshold = 2.0; + public double RansacThreshold { get => _ransacThreshold; set => SetProperty(ref _ransacThreshold, value); } + + private string _resultText = "就绪 - 点击「画卡尺」开始"; + public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); } + + // ── 初始化 ── + public void SetCanvas(PolygonRoiCanvas canvas) + { + _canvas = canvas; + _mainCanvas = FindChild(canvas, "mainCanvas"); + } + + /// 面板关闭时调用:仅清除临时编辑状态,保留已拟合结果 + public void OnPanelClosed() + { + UnregisterAll(); + ClearTempOverlays(); // 清除正在编辑的卡尺手柄 + // _committedOverlays 保留在画布上不清除 + } + + // ══════════════════════════════════════════════════════════════ + // 命令实现 + // ══════════════════════════════════════════════════════════════ + + /// 开始一次新的卡尺绘制(不影响已有结果) + private void ExecuteDrawCaliper() + { + // 清除当前临时编辑 + ClearTempOverlays(); + UnregisterAll(); + _lineDefined = false; + _dragging = DragTarget.None; + FitCommand.RaiseCanExecuteChanged(); + _drawClickCount = 0; + _isDrawingLine = true; + ResultText = "请在图像上点击搜索线起点"; + RegisterInteraction(); + } + + /// 执行拟合,将结果提交为永久显示 + private void ExecuteFit() + { + if (!_lineDefined) return; + + var imageSource = _viewportService?.CurrentDisplayImage as BitmapSource; + if (imageSource == null) { ResultText = "错误:无可用图像"; return; } + + try + { + BitmapSource source = imageSource; + if (imageSource.Format != PixelFormats.Gray8) + source = new FormatConvertedBitmap(imageSource, PixelFormats.Gray8, null, 0); + + int w = source.PixelWidth, h = source.PixelHeight; + int stride = w; + byte[] px = new byte[h * stride]; + source.CopyPixels(px, stride, 0); + + using var img = new Image(w, h); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + img.Data[y, x, 0] = px[y * stride + x]; + + var proc = new EdgeLineFitProcessor(); + proc.SetParameter("StartX", (int)_lineStart.X); + proc.SetParameter("StartY", (int)_lineStart.Y); + proc.SetParameter("EndX", (int)_lineEnd.X); + proc.SetParameter("EndY", (int)_lineEnd.Y); + proc.SetParameter("CaliperCount", CaliperCount); + proc.SetParameter("CaliperWidth", (int)(_halfWidth * 2)); + proc.SetParameter("EdgePolarity", EdgePolarity); + proc.SetParameter("EdgeThreshold", EdgeThreshold); + proc.SetParameter("Sigma", Sigma); + proc.SetParameter("FitMethod", FitMethod); + proc.SetParameter("RansacThreshold", RansacThreshold); + + var result = proc.Process(img); + var od = proc.OutputData; + + if (od.ContainsKey("LineFitResult")) + { + var fr = od["LineFitResult"] as LineFitResult; + if (fr != null && fr.Success) + { + _fitCount++; + // 将当前卡尺从临时转为永久 + CommitCurrentCaliper(); + // 绘制拟合结果(永久) + DrawFitResult(fr, _fitCount); + ResultText = $"[#{_fitCount}] 拟合成功\n角度: {fr.AngleDegrees:F2}°\n" + + $"内点: {fr.Inliers.Count}/{fr.EdgePointCount}\n" + + $"误差: {fr.FitError:F3} px\n\n点击「画卡尺」继续下一条"; + // 拟合完成后清除编辑状态,准备下一次 + _lineDefined = false; + FitCommand.RaiseCanExecuteChanged(); + UnregisterAll(); + } + else + { + int ec = od.ContainsKey("EdgePointCount") ? (int)od["EdgePointCount"] : 0; + ResultText = $"拟合失败\n边缘点: {ec}\n请调整参数或拖拽手柄"; + } + } + result.Dispose(); + } + catch (Exception ex) { ResultText = $"异常: {ex.Message}"; } + } + + /// 清除所有(包括已拟合的结果) + private void ExecuteClearAll() + { + ClearTempOverlays(); + // 清除所有已提交的结果 + if (_mainCanvas != null) + { + foreach (var el in _committedOverlays) + _mainCanvas.Children.Remove(el); + } + _committedOverlays.Clear(); + _fitCount = 0; + UnregisterAll(); + _lineDefined = false; + _dragging = DragTarget.None; + FitCommand.RaiseCanExecuteChanged(); + ResultText = "已清除所有结果"; + } + + // ══════════════════════════════════════════════════════════════ + // 提交当前卡尺为永久显示 + // ══════════════════════════════════════════════════════════════ + + /// 将当前临时卡尺可视化转为永久(去掉手柄,保留边框和等分线) + private void CommitCurrentCaliper() + { + if (_mainCanvas == null) return; + + // 移除临时元素 + foreach (var el in _tempOverlays) + _mainCanvas.Children.Remove(el); + _tempOverlays.Clear(); + + // 重新绘制卡尺(无手柄,作为永久元素) + double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 2) return; + + double ux = dx / len, uy = dy / len; + double px = -uy, py = ux; + double hw = _halfWidth; + + var c1 = new Point(_lineStart.X + px * hw, _lineStart.Y + py * hw); + var c2 = new Point(_lineEnd.X + px * hw, _lineEnd.Y + py * hw); + var c3 = new Point(_lineEnd.X - px * hw, _lineEnd.Y - py * hw); + var c4 = new Point(_lineStart.X - px * hw, _lineStart.Y - py * hw); + + // 矩形边框(半透明,不抢眼) + var border = new Polygon + { + Points = new System.Windows.Media.PointCollection { c1, c2, c3, c4 }, + Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.5, + Fill = System.Windows.Media.Brushes.Transparent, IsHitTestVisible = false + }; + _mainCanvas.Children.Add(border); + _committedOverlays.Add(border); + + // 等分线 + int count = CaliperCount; + double step = len / (count + 1); + for (int i = 1; i <= count; i++) + { + double cx = _lineStart.X + ux * step * i, cy = _lineStart.Y + uy * step * i; + var line = new Line + { + X1 = cx + px * hw, Y1 = cy + py * hw, + X2 = cx - px * hw, Y2 = cy - py * hw, + Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.3, IsHitTestVisible = false + }; + _mainCanvas.Children.Add(line); + _committedOverlays.Add(line); + } + } + + // ══════════════════════════════════════════════════════════════ + // 绘制拟合结果(永久) + // ══════════════════════════════════════════════════════════════ + + private void DrawFitResult(LineFitResult fr, int index) + { + if (_mainCanvas == null) return; + + // 拟合直线(蓝色) + AddCommitted(new Line + { + X1 = fr.Endpoint1.X, Y1 = fr.Endpoint1.Y, + X2 = fr.Endpoint2.X, Y2 = fr.Endpoint2.Y, + Stroke = FitLineBrush, StrokeThickness = 2, IsHitTestVisible = false + }); + + // 内点 + foreach (var pt in fr.Inliers) + AddCommitted(MakeDot(pt.X, pt.Y, Brushes.Lime)); + // 外点 + foreach (var pt in fr.Outliers) + AddCommitted(MakeDot(pt.X, pt.Y, Brushes.Red)); + + // 标注 + var lbl = new TextBlock + { + Text = $"#{index} ∠{fr.AngleDegrees:F2}° Err:{fr.FitError:F2}px", + Foreground = FitLineBrush, FontSize = 11, FontWeight = FontWeights.Bold, IsHitTestVisible = false + }; + Canvas.SetLeft(lbl, (fr.Endpoint1.X + fr.Endpoint2.X) / 2 + 5); + Canvas.SetTop(lbl, (fr.Endpoint1.Y + fr.Endpoint2.Y) / 2 - 18); + AddCommitted(lbl); + } + + private Ellipse MakeDot(double x, double y, SolidColorBrush fill) + { + var d = new Ellipse { Width = 5, Height = 5, Fill = fill, IsHitTestVisible = false }; + Canvas.SetLeft(d, x - 2.5); Canvas.SetTop(d, y - 2.5); + return d; + } + + private void AddCommitted(UIElement el) + { + _mainCanvas.Children.Add(el); + _committedOverlays.Add(el); + } + + // ══════════════════════════════════════════════════════════════ + // 临时卡尺可视化(编辑中,带手柄) + // ══════════════════════════════════════════════════════════════ + + private void RedrawTemp() + { + if (!_lineDefined || _mainCanvas == null) return; + ClearTempOverlays(); + DrawTempCaliper(); + } + + private void DrawTempCaliper() + { + if (_mainCanvas == null) return; + + double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 2) return; + + double ux = dx / len, uy = dy / len; + double px = -uy, py = ux; + double hw = _halfWidth; + + var c1 = new Point(_lineStart.X + px * hw, _lineStart.Y + py * hw); + var c2 = new Point(_lineEnd.X + px * hw, _lineEnd.Y + py * hw); + var c3 = new Point(_lineEnd.X - px * hw, _lineEnd.Y - py * hw); + var c4 = new Point(_lineStart.X - px * hw, _lineStart.Y - py * hw); + + // 矩形 + AddTemp(new Polygon + { + Points = new System.Windows.Media.PointCollection { c1, c2, c3, c4 }, + Stroke = CaliperStroke, StrokeThickness = 1, Fill = CaliperFill, IsHitTestVisible = false + }); + + // 搜索线虚线 + AddTemp(new Line + { + X1 = _lineStart.X, Y1 = _lineStart.Y, X2 = _lineEnd.X, Y2 = _lineEnd.Y, + Stroke = CaliperStroke, StrokeThickness = 1, + StrokeDashArray = new DoubleCollection { 4, 3 }, IsHitTestVisible = false + }); + + // 等分线 + int count = CaliperCount; + double step = len / (count + 1); + for (int i = 1; i <= count; i++) + { + double cx = _lineStart.X + ux * step * i, cy = _lineStart.Y + uy * step * i; + AddTemp(new Line + { + X1 = cx + px * hw, Y1 = cy + py * hw, + X2 = cx - px * hw, Y2 = cy - py * hw, + Stroke = CaliperStroke, StrokeThickness = 1, Opacity = 0.4, IsHitTestVisible = false + }); + } + + // 极性箭头 + DrawPolarityArrow(px, py); + + // 手柄位置 + double midX = (_lineStart.X + _lineEnd.X) / 2, midY = (_lineStart.Y + _lineEnd.Y) / 2; + _handleStartPos = _lineStart; + _handleEndPos = _lineEnd; + _handleTopPos = new Point(midX + px * hw, midY + py * hw); + _handleBottomPos = new Point(midX - px * hw, midY - py * hw); + + // 绘制手柄 + AddTemp(MakeHandleVisual(_handleStartPos)); + AddTemp(MakeHandleVisual(_handleEndPos)); + AddTemp(MakeHandleVisual(_handleTopPos)); + AddTemp(MakeHandleVisual(_handleBottomPos)); + } + + private void DrawPolarityArrow(double px, double py) + { + double midX = (_lineStart.X + _lineEnd.X) / 2, midY = (_lineStart.Y + _lineEnd.Y) / 2; + double arrowLen = Math.Min(_halfWidth * 0.6, 16); + + if (EdgePolarity == "Both") + { + DrawArrow(midX, midY, px, py, arrowLen); + DrawArrow(midX, midY, -px, -py, arrowLen); + } + else if (EdgePolarity == "DarkToBright") + DrawArrow(midX, midY, px, py, arrowLen); + else + DrawArrow(midX, midY, -px, -py, arrowLen); + + string txt = EdgePolarity switch { "BrightToDark" => "B→D", "DarkToBright" => "D→B", _ => "↔" }; + var tb = new TextBlock { Text = txt, Foreground = CaliperStroke, FontSize = 10, IsHitTestVisible = false }; + Canvas.SetLeft(tb, midX + px * (_halfWidth + 12)); + Canvas.SetTop(tb, midY + py * (_halfWidth + 12) - 7); + AddTemp(tb); + } + + private void DrawArrow(double fx, double fy, double dx, double dy, double length) + { + double tx = fx + dx * length, ty = fy + dy * length; + AddTemp(new Line { X1 = fx, Y1 = fy, X2 = tx, Y2 = ty, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false }); + double ang = Math.Atan2(dy, dx), hl = 5; + double a1 = ang + 2.5, a2 = ang - 2.5; + AddTemp(new Line { X1 = tx, Y1 = ty, X2 = tx + Math.Cos(a1) * hl, Y2 = ty + Math.Sin(a1) * hl, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false }); + AddTemp(new Line { X1 = tx, Y1 = ty, X2 = tx + Math.Cos(a2) * hl, Y2 = ty + Math.Sin(a2) * hl, Stroke = CaliperStroke, StrokeThickness = 1.5, IsHitTestVisible = false }); + } + + private Ellipse MakeHandleVisual(Point pos) + { + var h = new Ellipse + { + Width = HandleSize, Height = HandleSize, + Fill = HandleFill, Stroke = CaliperStroke, StrokeThickness = 2, + IsHitTestVisible = false + }; + Canvas.SetLeft(h, pos.X - HandleSize / 2); + Canvas.SetTop(h, pos.Y - HandleSize / 2); + return h; + } + + private void AddTemp(UIElement el) { _mainCanvas.Children.Add(el); _tempOverlays.Add(el); } + private void ClearTempOverlays() + { + if (_mainCanvas == null) return; + foreach (var el in _tempOverlays) _mainCanvas.Children.Remove(el); + _tempOverlays.Clear(); + } + + // ══════════════════════════════════════════════════════════════ + // 统一鼠标交互 + // ══════════════════════════════════════════════════════════════ + + private bool _interactionRegistered; + + private void RegisterInteraction() + { + if (_canvas == null || _interactionRegistered) return; + _canvas.PreviewMouseLeftButtonDown += OnMouseDown; + _canvas.PreviewMouseMove += OnMouseMove; + _canvas.PreviewMouseLeftButtonUp += OnMouseUp; + _interactionRegistered = true; + } + + private void UnregisterAll() + { + if (_canvas == null || !_interactionRegistered) return; + _canvas.PreviewMouseLeftButtonDown -= OnMouseDown; + _canvas.PreviewMouseMove -= OnMouseMove; + _canvas.PreviewMouseLeftButtonUp -= OnMouseUp; + _interactionRegistered = false; + _isDrawingLine = false; + _dragging = DragTarget.None; + } + + private void OnMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_mainCanvas == null) return; + var pos = e.GetPosition(_mainCanvas); + + // 绘制模式 + if (_isDrawingLine) + { + _drawClickCount++; + if (_drawClickCount == 1) + { + _lineStart = pos; + ResultText = "请点击搜索线终点"; + } + else if (_drawClickCount == 2) + { + _lineEnd = pos; + _isDrawingLine = false; + _lineDefined = true; + _halfWidth = DisplayWidth / 2.0; + FitCommand.RaiseCanExecuteChanged(); + RedrawTemp(); + ResultText = $"搜索线已定义 ({Len():F0}px)\n拖拽手柄调整,点击「拟合」执行"; + } + e.Handled = true; + return; + } + + // 拖拽模式 + if (_lineDefined) + { + var target = HitTestHandle(pos); + if (target != DragTarget.None) + { + _dragging = target; + _canvas.CaptureMouse(); + e.Handled = true; + } + } + } + + private void OnMouseMove(object sender, System.Windows.Input.MouseEventArgs e) + { + if (_dragging == DragTarget.None || _mainCanvas == null) return; + var pos = e.GetPosition(_mainCanvas); + + switch (_dragging) + { + case DragTarget.Start: + _lineStart = pos; + break; + case DragTarget.End: + _lineEnd = pos; + break; + case DragTarget.Top: + case DragTarget.Bottom: + double dist = PointToLineDist(pos, _lineStart, _lineEnd); + _halfWidth = Math.Max(5, dist); + SetProperty(ref _displayWidth, (int)(_halfWidth * 2), nameof(DisplayWidth)); + break; + } + + ClearTempOverlays(); + DrawTempCaliper(); + e.Handled = true; + } + + private void OnMouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_dragging == DragTarget.None) return; + _dragging = DragTarget.None; + _canvas.ReleaseMouseCapture(); + ResultText = $"搜索线: {Len():F0}px, 宽度: {(int)(_halfWidth * 2)}px\n点击「拟合」执行"; + e.Handled = true; + } + + private DragTarget HitTestHandle(Point pos) + { + if (Dist(pos, _handleStartPos) <= HitRadius) return DragTarget.Start; + if (Dist(pos, _handleEndPos) <= HitRadius) return DragTarget.End; + if (Dist(pos, _handleTopPos) <= HitRadius) return DragTarget.Top; + if (Dist(pos, _handleBottomPos) <= HitRadius) return DragTarget.Bottom; + return DragTarget.None; + } + + // ══════════════════════════════════════════════════════════════ + // 辅助 + // ══════════════════════════════════════════════════════════════ + + private double Len() + { + double dx = _lineEnd.X - _lineStart.X, dy = _lineEnd.Y - _lineStart.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + private static double Dist(Point a, Point b) + { + double dx = a.X - b.X, dy = a.Y - b.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + private static double PointToLineDist(Point p, Point a, Point b) + { + double abx = b.X - a.X, aby = b.Y - a.Y; + double len2 = abx * abx + aby * aby; + if (len2 < 1e-6) return Dist(p, a); + return Math.Abs(abx * (a.Y - p.Y) - aby * (a.X - p.X)) / Math.Sqrt(len2); + } + + private static T FindChild(DependencyObject parent, string name) where T : FrameworkElement + { + int count = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T t && t.Name == name) return t; + var r = FindChild(child, name); + if (r != null) return r; + } + return null; + } + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index e781ed2..a62cb20 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -143,6 +143,7 @@ namespace XplorePlane.ViewModels public DelegateCommand GrayscaleCommand { get; } public DelegateCommand SharpenCommand { get; } public DelegateCommand EnhanceCommand { get; } + public DelegateCommand EdgeLineFitCommand { get; } // 设置命令 public DelegateCommand OpenLanguageSwitcherCommand { get; } @@ -350,6 +351,7 @@ namespace XplorePlane.ViewModels GrayscaleCommand = new DelegateCommand(ExecuteGrayscale); SharpenCommand = new DelegateCommand(ExecuteSharpen); EnhanceCommand = new DelegateCommand(ExecuteEnhance); + EdgeLineFitCommand = new DelegateCommand(ExecuteEdgeLineFit); AxisResetCommand = new DelegateCommand(ExecuteAxisReset); OpenDoorCommand = new DelegateCommand(ExecuteOpenDoor); @@ -888,6 +890,7 @@ namespace XplorePlane.ViewModels } private Window _bgaDetectionPanel; + private Window _edgeLineFitPanel; private void ExecuteBgaDetection() { @@ -1172,6 +1175,25 @@ namespace XplorePlane.ViewModels } } + private void ExecuteEdgeLineFit() + { + if (!CheckImageLoaded()) return; + _logger.Info("边缘查找拟合直线功能已触发"); + + if (_edgeLineFitPanel != null && _edgeLineFitPanel.IsVisible) + { + _edgeLineFitPanel.Activate(); + return; + } + + _edgeLineFitPanel = new Views.ImageProcessing.EdgeLineFitPanel + { + Owner = System.Windows.Application.Current.MainWindow + }; + _edgeLineFitPanel.Closed += (_, _) => { _edgeLineFitPanel = null; }; + _edgeLineFitPanel.Show(); + } + private Image? BitmapSourceToImage(BitmapSource bitmapSource) { // 转换为可用的图像格式 diff --git a/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml b/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml new file mode 100644 index 0000000..f3868a1 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Both + BrightToDark + DarkToBright + + + + + + + + + + + + + + + + + + + + + RANSAC + LeastSquares + + + + + + + + + + + + + + + + + + + + diff --git a/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml.cs b/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml.cs new file mode 100644 index 0000000..c2dc50b --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/EdgeLineFitPanel.xaml.cs @@ -0,0 +1,53 @@ +using System.Windows; +using Prism.Ioc; +using XP.ImageProcessing.RoiControl.Controls; +using XplorePlane.Services.MainViewport; +using XplorePlane.ViewModels.ImageProcessing; + +namespace XplorePlane.Views.ImageProcessing +{ + public partial class EdgeLineFitPanel : Window + { + public EdgeLineFitPanel() + { + InitializeComponent(); + + var viewportService = ContainerLocator.Current?.Resolve(); + DataContext = new EdgeLineFitViewModel(viewportService); + + Loaded += (s, e) => + { + var mainWin = Owner as MainWindow; + if (mainWin != null) + { + var canvas = FindChild(mainWin); + if (DataContext is EdgeLineFitViewModel vm) + { + vm.SetCanvas(canvas); + // 自动进入绘制模式 + vm.DrawCaliperCommand.Execute(); + } + } + }; + + Closed += (s, e) => + { + if (DataContext is EdgeLineFitViewModel vm) + vm.OnPanelClosed(); + }; + } + + 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 b63c79e..ac6d80d 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -213,6 +213,13 @@ Size="Medium" SmallImage="/Assets/Icons/dynamic-range.png" Text="增强" /> + diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs index f7e90a3..652e5f6 100644 --- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs +++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs @@ -76,6 +76,7 @@ namespace XplorePlane.Views "FillRate" => "填锡率", "BgaVoid" => "BGA空隙", "BubbleVoid" => "气泡空隙", + "EdgeLineFit" => "直线拟合", _ => "点点距" }; string valueText = args.MeasureType switch @@ -84,9 +85,16 @@ namespace XplorePlane.Views "FillRate" => $"{args.Distance:F1}%", "BgaVoid" => $"{args.Distance:F1}%", "BubbleVoid" => $"{args.Distance:F1}%", + "EdgeLineFit" => "处理中...", _ => $"{args.Distance:F2} px" }; SetStatus($"{typeLabel}: {valueText} | 共 {args.TotalCount} 条测量"); + + // 边缘查找拟合直线:获取搜索线后执行算子 + if (args.MeasureType == "EdgeLineFit") + { + ExecuteEdgeLineFitProcessor(args.P1, args.P2); + } } }; RoiCanvas.MeasureStatusChanged += (s, e) => @@ -115,6 +123,7 @@ namespace XplorePlane.Views MeasurementToolMode.ThroughHoleFillRate => XP.ImageProcessing.RoiControl.Models.MeasureMode.FillRate, MeasurementToolMode.BgaVoid => XP.ImageProcessing.RoiControl.Models.MeasureMode.BgaVoid, MeasurementToolMode.BubbleMeasure => XP.ImageProcessing.RoiControl.Models.MeasureMode.BubbleMeasure, + MeasurementToolMode.EdgeLineFit => XP.ImageProcessing.RoiControl.Models.MeasureMode.EdgeLineFit, _ => XP.ImageProcessing.RoiControl.Models.MeasureMode.None }; }, Prism.Events.ThreadOption.UIThread); @@ -782,6 +791,154 @@ namespace XplorePlane.Views #endregion + #region 边缘查找拟合直线 + + private void ExecuteEdgeLineFitProcessor(Point startPoint, Point endPoint) + { + try + { + var vm = GetMainVm(); + if (vm == null) return; + + // 获取当前图像 + var viewportVm = ContainerLocator.Current?.Resolve(); + var imageSource = viewportVm?.ImageSource as BitmapSource; + if (imageSource == null) + { + SetStatus("直线拟合失败:无可用图像"); + return; + } + + // 转换为 Emgu.CV Image + BitmapSource source = imageSource; + if (imageSource.Format != System.Windows.Media.PixelFormats.Gray8) + source = new FormatConvertedBitmap(imageSource, System.Windows.Media.PixelFormats.Gray8, null, 0); + + int width = source.PixelWidth; + int height = source.PixelHeight; + int stride = width; + byte[] pixels = new byte[height * stride]; + source.CopyPixels(pixels, stride, 0); + + using var inputImage = new Emgu.CV.Image(width, height); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + inputImage.Data[y, x, 0] = pixels[y * stride + x]; + + // 创建并配置算子 + var processor = new XP.ImageProcessing.Processors.EdgeLineFitProcessor(); + processor.SetParameter("StartX", (int)startPoint.X); + processor.SetParameter("StartY", (int)startPoint.Y); + processor.SetParameter("EndX", (int)endPoint.X); + processor.SetParameter("EndY", (int)endPoint.Y); + + // 执行处理 + var result = processor.Process(inputImage); + + // 获取输出数据并在画布上绘制结果 + var outputData = processor.OutputData; + if (outputData.ContainsKey("LineFitResult")) + { + var fitResult = outputData["LineFitResult"] as XP.ImageProcessing.Processors.LineFitResult; + if (fitResult != null && fitResult.Success) + { + DrawEdgeLineFitResult(fitResult, outputData); + SetStatus($"直线拟合完成: 角度={fitResult.AngleDegrees:F2}°, 内点={fitResult.Inliers.Count}/{fitResult.EdgePointCount}, 误差={fitResult.FitError:F3}px"); + } + else + { + SetStatus("直线拟合失败:未找到足够的边缘点"); + } + } + + result.Dispose(); + } + catch (Exception ex) + { + SetStatus($"直线拟合异常: {ex.Message}"); + } + } + + private readonly System.Collections.Generic.List _elfOverlays = new(); + + private void DrawEdgeLineFitResult( + XP.ImageProcessing.Processors.LineFitResult fitResult, + System.Collections.Generic.Dictionary outputData) + { + var canvas = FindChildByName(RoiCanvas, "mainCanvas"); + if (canvas == null) return; + + // 清除之前的拟合结果 + foreach (var el in _elfOverlays) + canvas.Children.Remove(el); + _elfOverlays.Clear(); + + // 绘制拟合直线(绿色) + var fitLine = new System.Windows.Shapes.Line + { + X1 = fitResult.Endpoint1.X, + Y1 = fitResult.Endpoint1.Y, + X2 = fitResult.Endpoint2.X, + Y2 = fitResult.Endpoint2.Y, + Stroke = System.Windows.Media.Brushes.Lime, + StrokeThickness = 2, + IsHitTestVisible = false + }; + canvas.Children.Add(fitLine); + _elfOverlays.Add(fitLine); + + // 绘制内点(绿色小圆点) + foreach (var pt in fitResult.Inliers) + { + var dot = new System.Windows.Shapes.Ellipse + { + Width = 6, + Height = 6, + Fill = System.Windows.Media.Brushes.Lime, + IsHitTestVisible = false + }; + System.Windows.Controls.Canvas.SetLeft(dot, pt.X - 3); + System.Windows.Controls.Canvas.SetTop(dot, pt.Y - 3); + canvas.Children.Add(dot); + _elfOverlays.Add(dot); + } + + // 绘制外点(红色小圆点) + foreach (var pt in fitResult.Outliers) + { + var dot = new System.Windows.Shapes.Ellipse + { + Width = 6, + Height = 6, + Fill = System.Windows.Media.Brushes.Red, + IsHitTestVisible = false + }; + System.Windows.Controls.Canvas.SetLeft(dot, pt.X - 3); + System.Windows.Controls.Canvas.SetTop(dot, pt.Y - 3); + canvas.Children.Add(dot); + _elfOverlays.Add(dot); + } + + // 绘制角度标注 + var labelText = $"∠{fitResult.AngleDegrees:F2}° | Err:{fitResult.FitError:F2}px"; + var label = new System.Windows.Controls.TextBlock + { + Text = labelText, + Foreground = System.Windows.Media.Brushes.Yellow, + FontSize = 12, + FontWeight = FontWeights.Bold, + IsHitTestVisible = false + }; + double labelX = (fitResult.Endpoint1.X + fitResult.Endpoint2.X) / 2 + 5; + double labelY = (fitResult.Endpoint1.Y + fitResult.Endpoint2.Y) / 2 - 20; + System.Windows.Controls.Canvas.SetLeft(label, labelX); + System.Windows.Controls.Canvas.SetTop(label, labelY); + canvas.Children.Add(label); + _elfOverlays.Add(label); + } + + #endregion + private static T FindChildByName(DependencyObject parent, string name) where T : FrameworkElement { int count = VisualTreeHelper.GetChildrenCount(parent);