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