Files
XplorePlane/XP.ImageProcessing.Processors/检测分析/EdgeLineFitProcessor.cs
T
李伟 12938764b1 feat: 新增边缘查找拟合直线工具
- 新增 EdgeLineFitProcessor 算子(卡尺边缘检测 + 最小二乘/RANSAC直线拟合)
- 新增 EdgeLineFitPanel 辅助面板(参数配置、交互绘制卡尺)
- 支持任意角度旋转的卡尺区域,4个手柄控制长度/宽度
- 支持多次拟合累积显示,关闭面板后结果保留
- 极性箭头标识搜索方向(B→D / D→B / 双向)
- 卡尺亮绿色1px,拟合直线蓝色2px
- Ribbon快捷工具组新增「直线拟合」按钮
- 添加中英文本地化资源
2026-05-15 15:44:18 +08:00

639 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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;
/// <summary>
/// 边缘点信息
/// </summary>
public class EdgePointInfo
{
/// <summary>边缘点坐标(亚像素)</summary>
public PointF Position { get; set; }
/// <summary>边缘强度(梯度绝对值)</summary>
public double Strength { get; set; }
/// <summary>卡尺索引</summary>
public int CaliperIndex { get; set; }
/// <summary>是否为拟合内点</summary>
public bool IsInlier { get; set; } = true;
}
/// <summary>
/// 直线拟合结果
/// </summary>
public class LineFitResult
{
/// <summary>拟合是否成功</summary>
public bool Success { get; set; }
/// <summary>直线方向向量 (vx, vy)</summary>
public PointF Direction { get; set; }
/// <summary>直线上一点 (x0, y0)</summary>
public PointF PointOnLine { get; set; }
/// <summary>直线角度(度,相对于X轴)</summary>
public double AngleDegrees { get; set; }
/// <summary>直线端点1(用于绘制)</summary>
public PointF Endpoint1 { get; set; }
/// <summary>直线端点2(用于绘制)</summary>
public PointF Endpoint2 { get; set; }
/// <summary>所有检测到的边缘点</summary>
public List<EdgePointInfo> EdgePoints { get; set; } = new();
/// <summary>内点列表</summary>
public List<PointF> Inliers { get; set; } = new();
/// <summary>外点列表</summary>
public List<PointF> Outliers { get; set; } = new();
/// <summary>平均拟合误差(像素)</summary>
public double FitError { get; set; }
/// <summary>有效边缘点数</summary>
public int EdgePointCount { get; set; }
}
/// <summary>
/// 边缘查找拟合直线算子 - 使用卡尺法检测边缘点并拟合直线
/// </summary>
public class EdgeLineFitProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<EdgeLineFitProcessor>();
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<Gray, byte> Process(Image<Gray, byte> inputImage)
{
// 读取参数
int startX = GetParameter<int>("StartX");
int startY = GetParameter<int>("StartY");
int endX = GetParameter<int>("EndX");
int endY = GetParameter<int>("EndY");
int caliperCount = GetParameter<int>("CaliperCount");
int caliperWidth = GetParameter<int>("CaliperWidth");
string edgePolarity = GetParameter<string>("EdgePolarity");
int edgeThreshold = GetParameter<int>("EdgeThreshold");
double sigma = GetParameter<double>("Sigma");
string fitMethod = GetParameter<string>("FitMethod");
double ransacThreshold = GetParameter<double>("RansacThreshold");
int thickness = GetParameter<int>("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<EdgePointInfo>();
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();
}
/// <summary>
/// 在单个卡尺内查找边缘点
/// </summary>
private EdgePointInfo? FindEdgeInCaliper(
Image<Gray, byte> 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
};
}
/// <summary>
/// 一维高斯平滑
/// </summary>
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;
}
/// <summary>
/// 拟合直线
/// </summary>
private LineFitResult FitLine(List<EdgePointInfo> 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);
}
}
/// <summary>
/// 最小二乘直线拟合(使用OpenCV FitLine
/// </summary>
private LineFitResult FitLineLeastSquares(List<EdgePointInfo> 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<PointF>();
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;
}
/// <summary>
/// RANSAC直线拟合
/// </summary>
private LineFitResult FitLineRANSAC(List<EdgePointInfo> 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<int> 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<int>();
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<PointF>();
var outliers = new List<PointF>();
var inlierSet = new HashSet<int>(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;
}
/// <summary>
/// 计算点到直线的距离
/// </summary>
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);
}
/// <summary>
/// 计算直线端点(基于边缘点的投影范围)
/// </summary>
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));
}
/// <summary>
/// 计算平均拟合误差
/// </summary>
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;
}
}