// ============================================================================
// 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;
}
}