diff --git a/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs index c3b901c..5d6b31f 100644 --- a/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs @@ -125,7 +125,7 @@ namespace XplorePlane.ViewModels.ImageProcessing } }; - // 禁用右键菜单(参考点点距方式) + // 禁用右键菜单 _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, false); // 订阅画布点击事件来添加 ROI 顶点 diff --git a/XplorePlane/ViewModels/ImageProcessing/VoidDetectionViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/VoidDetectionViewModel.cs new file mode 100644 index 0000000..96e7646 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/VoidDetectionViewModel.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using Prism.Commands; +using Prism.Mvvm; +using XP.ImageProcessing.Processors; +using XP.ImageProcessing.RoiControl.Controls; +using XP.ImageProcessing.RoiControl.Models; +using XplorePlane.Services.MainViewport; + +namespace XplorePlane.ViewModels.ImageProcessing +{ + public class VoidDetectionViewModel : BindableBase + { + private readonly IMainViewportService _viewportService; + private BitmapSource _originalImage; + private System.Threading.CancellationTokenSource _debounceCts; + private const int DebounceMs = 300; + + public VoidDetectionViewModel(IMainViewportService viewportService) + { + _viewportService = viewportService; + ExecuteCommand = new DelegateCommand(Execute); + PropertyChanged += OnAnyPropertyChanged; + } + + private void OnAnyPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ResultText) || e.PropertyName == nameof(ResultImage) || e.PropertyName == nameof(RoiEnabled)) + return; + TriggerDebouncedExecution(); + } + + private void TriggerDebouncedExecution() + { + _debounceCts?.Cancel(); + _debounceCts = new System.Threading.CancellationTokenSource(); + var token = _debounceCts.Token; + System.Threading.Tasks.Task.Delay(DebounceMs, token).ContinueWith(t => + { + if (!t.IsCanceled) Execute(); + }, System.Threading.Tasks.TaskScheduler.FromCurrentSynchronizationContext()); + } + + // 参数 + private int _minThreshold = 128; + public int MinThreshold { get => _minThreshold; set => SetProperty(ref _minThreshold, value); } + + private int _maxThreshold = 255; + public int MaxThreshold { get => _maxThreshold; set => SetProperty(ref _maxThreshold, value); } + + private int _minVoidArea = 10; + public int MinVoidArea { get => _minVoidArea; set => SetProperty(ref _minVoidArea, value); } + + private int _mergeRadius = 3; + public int MergeRadius { get => _mergeRadius; set => SetProperty(ref _mergeRadius, value); } + + private int _blurSize = 3; + public int BlurSize { get => _blurSize; set => SetProperty(ref _blurSize, value); } + + private double _voidLimit = 25.0; + public double VoidLimit { get => _voidLimit; set => SetProperty(ref _voidLimit, value); } + + // ROI + private bool _roiEnabled; + public bool RoiEnabled + { + get => _roiEnabled; + set + { + if (SetProperty(ref _roiEnabled, value)) + OnRoiEnabledChanged(); + } + } + + private PolygonRoiCanvas _canvas; + private PolygonROI _roiShape; + private System.Windows.Controls.Image _resultOverlayImage; + + public void SetCanvas(PolygonRoiCanvas canvas) => _canvas = canvas; + + private void OnRoiEnabledChanged() + { + if (_canvas == null) return; + if (RoiEnabled) + { + if (_canvas.ROIItems == null) + _canvas.ROIItems = new ObservableCollection(); + _roiShape = new PolygonROI { Color = "Cyan", IsSelected = true }; + _canvas.ROIItems.Add(_roiShape); + _canvas.SelectedROI = _roiShape; + _roiShape.Points.CollectionChanged += (s, e) => + { + if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add || + e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Remove) + { + _canvas.SelectedROI = null; + _canvas.SelectedROI = _roiShape; + } + }; + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, false); + _canvas.AddHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + } + else + { + CleanupRoi(); + } + } + + private void OnCanvasClickedForRoi(object sender, RoutedEventArgs e) + { + if (!RoiEnabled || _roiShape == null) return; + if (e is CanvasClickedEventArgs args) + { + InsertPointToPolygon(args.Position, _roiShape.Points); + _roiShape.IsSelected = true; + _canvas.SelectedROI = _roiShape; + } + } + + public void CleanupRoi() + { + if (_canvas != null) + { + _canvas.RemoveHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, true); + } + if (_roiShape != null && _canvas?.ROIItems != null) + { + _canvas.ROIItems.Remove(_roiShape); + _canvas.SelectedROI = null; + _roiShape = null; + } + } + + public void RestoreContextMenu() + { + if (_canvas != null) + { + _canvas.RemoveHandler(PolygonRoiCanvas.CanvasClickedEvent, new RoutedEventHandler(OnCanvasClickedForRoi)); + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, true); + if (_roiShape != null) + { + _roiShape.IsSelected = false; + _roiShape.IsEditable = false; + } + _canvas.SelectedROI = null; + } + } + + // 结果 + private string _resultText = "结果: --"; + public string ResultText { get => _resultText; set => SetProperty(ref _resultText, value); } + + private BitmapSource _resultImage; + public BitmapSource ResultImage { get => _resultImage; set => SetProperty(ref _resultImage, value); } + + public ObservableCollection Results { get; } = new(); + + public DelegateCommand ExecuteCommand { get; } + + private void Execute() + { + if (_originalImage == null) + _originalImage = _viewportService?.CurrentDisplayImage as BitmapSource; + var image = _originalImage; + if (image == null) { ResultText = "请先加载图像"; return; } + + try + { + var processor = new VoidMeasurementProcessor(); + processor.SetParameter("MinThreshold", MinThreshold); + processor.SetParameter("MaxThreshold", MaxThreshold); + processor.SetParameter("MinVoidArea", MinVoidArea); + processor.SetParameter("MergeRadius", MergeRadius); + processor.SetParameter("BlurSize", BlurSize); + processor.SetParameter("VoidLimit", VoidLimit); + + // ROI 注入 + if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3) + { + int count = Math.Min(_roiShape.Points.Count, 32); + processor.SetParameter("PolyCount", count); + for (int i = 0; i < count; i++) + { + processor.SetParameter($"PolyX{i}", (int)_roiShape.Points[i].X); + processor.SetParameter($"PolyY{i}", (int)_roiShape.Points[i].Y); + } + } + + var grayImage = BitmapSourceToGray(image); + processor.Process(grayImage); + var output = processor.OutputData; + + ResultText = output.ContainsKey("ResultText") + ? output["ResultText"]?.ToString() ?? "--" + : "未检测到空隙"; + + // 填充结果表格 + Results.Clear(); + if (output.ContainsKey("Voids")) + { + var voids = output["Voids"] as List; + if (voids != null) + { + foreach (var v in voids) + { + Results.Add(new VoidResultItem + { + Index = v.Index, + CenterX = v.CenterX.ToString("F1"), + CenterY = v.CenterY.ToString("F1"), + Area = v.Area.ToString(), + AreaPercent = $"{v.AreaPercent:F2}%" + }); + } + } + } + + ResultImage = RenderResults(grayImage, output); + ShowResultOnOverlay(ResultImage); + grayImage.Dispose(); + } + catch (Exception ex) + { + ResultText = $"错误: {ex.Message}"; + } + } + + private void ShowResultOnOverlay(BitmapSource resultBmp) + { + if (_canvas == null) return; + RemoveResultOverlay(); + if (resultBmp == null) return; + + _resultOverlayImage = new System.Windows.Controls.Image + { + Source = resultBmp, + IsHitTestVisible = false, + Stretch = System.Windows.Media.Stretch.Fill + }; + _resultOverlayImage.SetBinding(System.Windows.FrameworkElement.WidthProperty, + new System.Windows.Data.Binding("CanvasWidth") { Source = _canvas }); + _resultOverlayImage.SetBinding(System.Windows.FrameworkElement.HeightProperty, + new System.Windows.Data.Binding("CanvasHeight") { Source = _canvas }); + + var mainCanvas = _canvas.FindName("mainCanvas") as System.Windows.Controls.Canvas; + if (mainCanvas != null) + { + int insertIndex = Math.Min(1, mainCanvas.Children.Count); + mainCanvas.Children.Insert(insertIndex, _resultOverlayImage); + } + } + + public void RemoveResultOverlay() + { + if (_resultOverlayImage == null || _canvas == null) return; + _canvas.RemoveFromCanvas(_resultOverlayImage); + _resultOverlayImage = null; + } + + private BitmapSource RenderResults(Image grayImage, IDictionary output) + { + if (!output.ContainsKey("VoidMeasurementResult")) return null; + int voidCount = (int)output["VoidCount"]; + if (voidCount == 0) return null; + + double voidRate = (double)output["VoidRate"]; + string classification = (string)output["Classification"]; + double voidLimitVal = (double)output["VoidLimit"]; + var voids = output["Voids"] as List; + + var colorImage = new Image(grayImage.Width, grayImage.Height); + CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr); + + if (voids != null && voids.Count > 0) + { + // 半透明气泡填充 + var overlay = colorImage.Clone(); + foreach (var v in voids) + { + if (v.ContourPoints.Length > 0) + { + using var vop = new VectorOfPoint(v.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(overlay, vvop, 0, new MCvScalar(0, 200, 255), -1); + } + } + CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage); + overlay.Dispose(); + + // 绘制轮廓 + 编号 + foreach (var v in voids) + { + if (v.ContourPoints.Length > 0) + { + using var vop = new VectorOfPoint(v.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(colorImage, vvop, 0, new MCvScalar(0, 255, 255), 1); + } + CvInvoke.PutText(colorImage, $"#{v.Index}", + new System.Drawing.Point((int)v.CenterX - 8, (int)v.CenterY + 5), + FontFace.HersheySimplex, 0.35, new MCvScalar(255, 100, 0), 1); + } + + // 左上角总览 + var overallColor = classification == "PASS" + ? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255); + CvInvoke.PutText(colorImage, + $"Void: {voidRate:F1}% | Limit: {voidLimitVal:F0}% | {voidCount} voids | {classification}", + new System.Drawing.Point(10, 25), + FontFace.HersheySimplex, 0.5, overallColor, 2); + } + + using var bitmap = colorImage.ToBitmap(); + var bmpSrc = System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap( + bitmap.GetHbitmap(), IntPtr.Zero, System.Windows.Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions()); + bmpSrc.Freeze(); + colorImage.Dispose(); + return bmpSrc; + } + + private static void InsertPointToPolygon(Point newPoint, ObservableCollection points) + { + if (points.Count < 2) { points.Add(newPoint); return; } + int insertIndex = 0; + double minDistance = double.MaxValue; + double d = PointToSegmentDistance(points[points.Count - 1], points[0], newPoint); + if (d < minDistance) { minDistance = d; insertIndex = 0; } + for (int i = 1; i < points.Count; i++) + { + d = PointToSegmentDistance(points[i - 1], points[i], newPoint); + if (d < minDistance) { minDistance = d; insertIndex = i; } + } + points.Insert(insertIndex, newPoint); + } + + private static double PointToSegmentDistance(Point a, Point b, Point p) + { + double dx = b.X - a.X, dy = b.Y - a.Y; + double lenSq = dx * dx + dy * dy; + if (lenSq < 1e-10) return Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y)); + double t = Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq, 0, 1); + double projX = a.X + t * dx, projY = a.Y + t * dy; + return Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY)); + } + + private static Image BitmapSourceToGray(BitmapSource bmp) + { + var converted = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0); + int w = converted.PixelWidth, h = converted.PixelHeight; + int stride = w * 4; + var pixels = new byte[stride * h]; + converted.CopyPixels(pixels, stride, 0); + var gray = new Image(w, h); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int idx = y * stride + x * 4; + gray.Data[y, x, 0] = (byte)(pixels[idx + 2] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx] * 0.114); + } + return gray; + } + } + + public class VoidResultItem + { + public int Index { get; set; } + public string CenterX { get; set; } = ""; + public string CenterY { get; set; } = ""; + public string Area { get; set; } = ""; + public string AreaPercent { get; set; } = ""; + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index d2ed694..51ce60d 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -92,6 +92,7 @@ namespace XplorePlane.ViewModels public DelegateCommand ThroughHoleFillRateMeasureCommand { get; } public DelegateCommand BgaVoidMeasureCommand { get; } public DelegateCommand BgaDetectionCommand { get; } + public DelegateCommand VoidDetectionCommand { get; } public DelegateCommand BubbleMeasureCommand { get; } // 辅助线命令 @@ -203,6 +204,7 @@ namespace XplorePlane.ViewModels ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure); BgaVoidMeasureCommand = new DelegateCommand(ExecuteBgaVoidMeasure); BgaDetectionCommand = new DelegateCommand(ExecuteBgaDetection); + VoidDetectionCommand = new DelegateCommand(ExecuteVoidDetection); BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure); // 辅助线命令 @@ -553,6 +555,26 @@ namespace XplorePlane.ViewModels _bgaDetectionPanel.Show(); } + private Window _voidDetectionPanel; + + private void ExecuteVoidDetection() + { + if (!CheckImageLoaded()) return; + _logger.Info("空隙检测功能已触发"); + + if (_voidDetectionPanel != null && _voidDetectionPanel.IsVisible) + { + _voidDetectionPanel.Activate(); + return; + } + + _voidDetectionPanel = new Views.ImageProcessing.VoidDetectionPanel + { + Owner = System.Windows.Application.Current.MainWindow + }; + _voidDetectionPanel.Show(); + } + private Window _bubbleMeasurePanel; private void ExecuteBubbleMeasure() diff --git a/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml b/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml new file mode 100644 index 0000000..71a9587 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml.cs b/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml.cs new file mode 100644 index 0000000..2f9e2b5 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/VoidDetectionPanel.xaml.cs @@ -0,0 +1,50 @@ +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 VoidDetectionPanel : Window + { + public VoidDetectionPanel() + { + InitializeComponent(); + var viewportService = ContainerLocator.Current?.Resolve(); + DataContext = new VoidDetectionViewModel(viewportService); + + Loaded += (s, e) => + { + var mainWin = Owner as MainWindow; + if (mainWin != null) + { + var canvas = FindChild(mainWin); + if (DataContext is VoidDetectionViewModel vm) + vm.SetCanvas(canvas); + } + }; + + Closed += (s, e) => + { + if (DataContext is VoidDetectionViewModel vm) + vm.RestoreContextMenu(); + }; + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private static T FindChild(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(child); + if (result != null) return result; + } + return null; + } + } +} diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml index b83d2f6..9c168f5 100644 --- a/XplorePlane/Views/Main/MainWindow.xaml +++ b/XplorePlane/Views/Main/MainWindow.xaml @@ -353,11 +353,10 @@ Size="Large" SmallImage="/Assets/Icons/bga.png" />