feat: 新增边缘查找拟合直线工具
- 新增 EdgeLineFitProcessor 算子(卡尺边缘检测 + 最小二乘/RANSAC直线拟合) - 新增 EdgeLineFitPanel 辅助面板(参数配置、交互绘制卡尺) - 支持任意角度旋转的卡尺区域,4个手柄控制长度/宽度 - 支持多次拟合累积显示,关闭面板后结果保留 - 极性箭头标识搜索方向(B→D / D→B / 双向) - 卡尺亮绿色1px,拟合直线蓝色2px - Ribbon快捷工具组新增「直线拟合」按钮 - 添加中英文本地化资源
This commit is contained in:
@@ -1887,4 +1887,60 @@ Reprojection error: {1:F4} pixels</value>
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>Image{0}: {1:F4} pixels</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>Edge Find Line Fit</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>Place calipers along a search line to detect edge points and fit a line (supports Least Squares and RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>Caliper Count</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>Number of calipers placed evenly along the search line</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>Caliper Width</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>Search length of each caliper (pixels), perpendicular to the search line</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>Edge Polarity</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>Edge direction: BrightToDark, DarkToBright, or Both</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>Edge Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>Gradient strength threshold; edges below this value are ignored</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>Smoothing Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>Gaussian smoothing standard deviation for noise suppression (larger = smoother)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>Fit Method</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>Line fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC Threshold</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC inlier distance threshold (pixels); points closer than this to the line are inliers</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>Line Thickness</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>Drawing thickness for result visualization</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1920,4 +1920,60 @@
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>图像{0}: {1:F4} 像素</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合直线</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿搜索线等间距放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺的搜索长度(像素),沿垂直于搜索线方向</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声(越大越平滑)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1881,4 +1881,60 @@
|
||||
<data name="ChessboardImageError" xml:space="preserve">
|
||||
<value>图像{0}: {1:F4} 像素</value>
|
||||
</data>
|
||||
|
||||
<!-- EdgeLineFitProcessor -->
|
||||
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
|
||||
<value>边缘查找拟合直线</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
|
||||
<value>沿搜索线放置卡尺检测边缘点,拟合直线(支持最小二乘和RANSAC)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
|
||||
<value>卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
|
||||
<value>沿搜索线等间距放置的卡尺数量</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
|
||||
<value>卡尺宽度</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
|
||||
<value>每个卡尺的搜索长度(像素),沿垂直于搜索线方向</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
|
||||
<value>边缘极性</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
|
||||
<value>边缘方向:BrightToDark(亮到暗)、DarkToBright(暗到亮)、Both(双向)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
|
||||
<value>边缘阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
|
||||
<value>边缘梯度强度阈值,低于此值的边缘将被忽略</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
|
||||
<value>平滑Sigma</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
|
||||
<value>高斯平滑的标准差,用于抑制噪声(越大越平滑)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
|
||||
<value>拟合方法</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
|
||||
<value>直线拟合算法:LeastSquares(最小二乘)、RANSAC(鲁棒拟合,可剔除异常点)</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
|
||||
<value>RANSAC阈值</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
|
||||
<value>RANSAC内点判定距离阈值(像素),点到直线距离小于此值视为内点</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
|
||||
<value>线条粗细</value>
|
||||
</data>
|
||||
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
|
||||
<value>绘制结果的线条粗细</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace XP.ImageProcessing.RoiControl.Models
|
||||
Angle,
|
||||
FillRate,
|
||||
BgaVoid,
|
||||
BubbleMeasure
|
||||
BubbleMeasure,
|
||||
EdgeLineFit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ namespace XplorePlane.Events
|
||||
Angle,
|
||||
ThroughHoleFillRate,
|
||||
BgaVoid,
|
||||
BubbleMeasure
|
||||
BubbleMeasure,
|
||||
EdgeLineFit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 边缘查找拟合直线 ViewModel
|
||||
/// 支持多次拟合,每次点击"画卡尺"开始一次新的测量,结果累积保留
|
||||
/// 关闭面板时保留所有结果,仅清除当前正在编辑的临时卡尺
|
||||
/// </summary>
|
||||
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<UIElement> _tempOverlays = new();
|
||||
|
||||
// 已完成的拟合结果(永久保留在画布上)
|
||||
// 不由本类管理生命周期,关闭面板后仍保留
|
||||
private readonly List<UIElement> _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>(canvas, "mainCanvas");
|
||||
}
|
||||
|
||||
/// <summary>面板关闭时调用:仅清除临时编辑状态,保留已拟合结果</summary>
|
||||
public void OnPanelClosed()
|
||||
{
|
||||
UnregisterAll();
|
||||
ClearTempOverlays(); // 清除正在编辑的卡尺手柄
|
||||
// _committedOverlays 保留在画布上不清除
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 命令实现
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>开始一次新的卡尺绘制(不影响已有结果)</summary>
|
||||
private void ExecuteDrawCaliper()
|
||||
{
|
||||
// 清除当前临时编辑
|
||||
ClearTempOverlays();
|
||||
UnregisterAll();
|
||||
_lineDefined = false;
|
||||
_dragging = DragTarget.None;
|
||||
FitCommand.RaiseCanExecuteChanged();
|
||||
_drawClickCount = 0;
|
||||
_isDrawingLine = true;
|
||||
ResultText = "请在图像上点击搜索线起点";
|
||||
RegisterInteraction();
|
||||
}
|
||||
|
||||
/// <summary>执行拟合,将结果提交为永久显示</summary>
|
||||
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<Gray, byte>(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}"; }
|
||||
}
|
||||
|
||||
/// <summary>清除所有(包括已拟合的结果)</summary>
|
||||
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 = "已清除所有结果";
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 提交当前卡尺为永久显示
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>将当前临时卡尺可视化转为永久(去掉手柄,保留边框和等分线)</summary>
|
||||
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<T>(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<T>(child, name);
|
||||
if (r != null) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Gray, byte>? BitmapSourceToImage(BitmapSource bitmapSource)
|
||||
{
|
||||
// 转换为可用的图像格式
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<Window
|
||||
x:Class="XplorePlane.Views.ImageProcessing.EdgeLineFitPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="边缘查找拟合直线" Width="300" Height="560"
|
||||
ResizeMode="CanResize" WindowStartupLocation="CenterOwner"
|
||||
Topmost="True" ShowInTaskbar="False"
|
||||
Background="#F5F5F5" FontFamily="Microsoft YaHei UI">
|
||||
<Window.Resources>
|
||||
<Style x:Key="IconBtnStyle" TargetType="ButtonBase">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ButtonBase">
|
||||
<Border x:Name="Bd" Background="#FFFFFF" BorderBrush="#E0E0E0"
|
||||
BorderThickness="1" CornerRadius="6" Padding="8,6">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#EAF2FB" />
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="#B0D4F1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style x:Key="CardStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderBrush" Value="#E8E8E8" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style x:Key="ParamLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="#555" />
|
||||
<Setter Property="Margin" Value="0,0,0,3" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
<!-- 工具栏 -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding DrawCaliperCommand}" ToolTip="绘制卡尺" Margin="0,0,6,0">
|
||||
<TextBlock Text="✏ 画卡尺" FontSize="11" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding FitCommand}" ToolTip="执行拟合" Margin="0,0,6,0">
|
||||
<TextBlock Text="▶ 拟合" FontSize="11" FontWeight="SemiBold" Foreground="#005FB8" />
|
||||
</Button>
|
||||
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding ClearAllCommand}" ToolTip="清除全部">
|
||||
<TextBlock Text="✕ 清除全部" FontSize="11" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 卡尺参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="卡尺参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="卡尺数量" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding CaliperCount, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="3" Maximum="100" Value="{Binding CaliperCount}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="卡尺宽度 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding DisplayWidth, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="10" Maximum="300" Value="{Binding DisplayWidth}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 边缘检测参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="边缘检测参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="边缘极性" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedValue="{Binding EdgePolarity}" Margin="0,0,0,6">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">Both</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">BrightToDark</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">DarkToBright</sys:String>
|
||||
</ComboBox>
|
||||
<TextBlock Text="边缘阈值" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding EdgeThreshold, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="1" Maximum="200" Value="{Binding EdgeThreshold}" VerticalAlignment="Center" SmallChange="1" IsSnapToTickEnabled="True" TickFrequency="1" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="平滑 Sigma" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding Sigma, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.1" Maximum="5.0" Value="{Binding Sigma}" SmallChange="0.1" LargeChange="0.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 拟合参数 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="拟合参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
|
||||
<TextBlock Text="拟合方法" Style="{StaticResource ParamLabel}" />
|
||||
<ComboBox SelectedValue="{Binding FitMethod}" Margin="0,0,0,6">
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">RANSAC</sys:String>
|
||||
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">LeastSquares</sys:String>
|
||||
</ComboBox>
|
||||
<TextBlock Text="RANSAC 阈值 (px)" Style="{StaticResource ParamLabel}" />
|
||||
<DockPanel>
|
||||
<TextBox DockPanel.Dock="Right" Width="50" Text="{Binding RansacThreshold, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
|
||||
<Slider Minimum="0.5" Maximum="20" Value="{Binding RansacThreshold}" SmallChange="0.5" LargeChange="2" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 结果 -->
|
||||
<Border Style="{StaticResource CardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="结果" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,6" />
|
||||
<TextBlock Text="{Binding ResultText}" FontSize="11.5" Foreground="#333" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -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<IMainViewportService>();
|
||||
DataContext = new EdgeLineFitViewModel(viewportService);
|
||||
|
||||
Loaded += (s, e) =>
|
||||
{
|
||||
var mainWin = Owner as MainWindow;
|
||||
if (mainWin != null)
|
||||
{
|
||||
var canvas = FindChild<PolygonRoiCanvas>(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<T>(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<T>(child);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,13 @@
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/dynamic-range.png"
|
||||
Text="增强" />
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Description="沿搜索线放置卡尺检测边缘点并拟合直线"
|
||||
telerik:ScreenTip.Title="直线拟合"
|
||||
Command="{Binding EdgeLineFitCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/ptol.png"
|
||||
Text="直线拟合" />
|
||||
</StackPanel>
|
||||
</telerik:RadRibbonGroup>
|
||||
|
||||
|
||||
@@ -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<ViewportPanelViewModel>();
|
||||
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<Emgu.CV.Structure.Gray, byte>(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<System.Windows.UIElement> _elfOverlays = new();
|
||||
|
||||
private void DrawEdgeLineFitResult(
|
||||
XP.ImageProcessing.Processors.LineFitResult fitResult,
|
||||
System.Collections.Generic.Dictionary<string, object> outputData)
|
||||
{
|
||||
var canvas = FindChildByName<System.Windows.Controls.Canvas>(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<T>(DependencyObject parent, string name) where T : FrameworkElement
|
||||
{
|
||||
int count = VisualTreeHelper.GetChildrenCount(parent);
|
||||
|
||||
Reference in New Issue
Block a user