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

632 lines
26 KiB
C#

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