diff --git a/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs new file mode 100644 index 0000000..914204a --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs @@ -0,0 +1,477 @@ +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 BgaDetectionViewModel : BindableBase + { + private readonly IMainViewportService _viewportService; + private BitmapSource _originalImage; + private System.Threading.CancellationTokenSource _debounceCts; + private const int DebounceMs = 300; + + public BgaDetectionViewModel(IMainViewportService viewportService) + { + _viewportService = viewportService; + ExecuteCommand = new DelegateCommand(Execute); + PropertyChanged += OnAnyPropertyChanged; + } + + private void OnAnyPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // 排除结果属性和ROI开关,只监听参数变化 + 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()); + } + + // BGA定位参数 + private int _bgaMinArea = 500; + public int BgaMinArea { get => _bgaMinArea; set => SetProperty(ref _bgaMinArea, value); } + + private int _bgaMaxArea = 500000; + public int BgaMaxArea { get => _bgaMaxArea; set => SetProperty(ref _bgaMaxArea, value); } + + private int _blurSize = 5; + public int BlurSize { get => _blurSize; set => SetProperty(ref _blurSize, value); } + + private double _circularity = 0.5; + public double Circularity { get => _circularity; set => SetProperty(ref _circularity, value); } + + // 气泡检测参数 + 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 double _voidLimit = 25.0; + public double VoidLimit { get => _voidLimit; set => SetProperty(ref _voidLimit, value); } + + private double _maxSingleVoidLimit = 10.0; + public double MaxSingleVoidLimit { get => _maxSingleVoidLimit; set => SetProperty(ref _maxSingleVoidLimit, value); } + + // ROI + private bool _roiEnabled; + public bool RoiEnabled + { + get => _roiEnabled; + set + { + if (SetProperty(ref _roiEnabled, value)) + OnRoiEnabledChanged(); + } + } + + private PolygonRoiCanvas _canvas; + private PolygonROI _roiShape; + + public void SetCanvas(PolygonRoiCanvas canvas) + { + _canvas = canvas; + } + + private void OnRoiEnabledChanged() + { + if (_canvas == null) return; + if (RoiEnabled) + { + // 确保 ROIItems 存在 + if (_canvas.ROIItems == null) + _canvas.ROIItems = new ObservableCollection(); + + _roiShape = new PolygonROI { Color = "Red", IsSelected = true }; + _canvas.ROIItems.Add(_roiShape); + _canvas.SelectedROI = _roiShape; + + // 手动注册 CollectionChanged + _roiShape.Points.CollectionChanged += (s, e) => + { + _canvas.SelectedROI = null; + _canvas.SelectedROI = _roiShape; + }; + + // 禁用右键菜单(参考点点距方式) + _canvas.SetValue(System.Windows.Controls.ContextMenuService.IsEnabledProperty, false); + + // 订阅画布点击事件来添加 ROI 顶点 + _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; + } + } + + /// 智能插入顶点:找到距离新点最近的边,将新点插入到该边的两个顶点之间 + 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)); + } + + 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 System.Collections.ObjectModel.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 BgaVoidRateProcessor(); + processor.SetParameter("BgaMinArea", BgaMinArea); + processor.SetParameter("BgaMaxArea", BgaMaxArea); + processor.SetParameter("BgaBlurSize", BlurSize); + processor.SetParameter("BgaCircularity", Circularity); + processor.SetParameter("MinThreshold", MinThreshold); + processor.SetParameter("MaxThreshold", MaxThreshold); + processor.SetParameter("MinVoidArea", MinVoidArea); + processor.SetParameter("VoidLimit", VoidLimit); + processor.SetParameter("RoiMode", "None"); + + // 如果有 ROI 多边形,注入坐标 + if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3) + { + processor.SetParameter("RoiMode", "Polygon"); + 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() ?? "--" + : "未检测到BGA焊球"; + + // 填充结果表格 + Results.Clear(); + if (output.ContainsKey("BgaBalls")) + { + var bgaBalls = output["BgaBalls"] as List; + if (bgaBalls != null) + { + // 统一排序:按行分组(Y坐标相近归为一行),行内按X排序 + var sorted = SortBgaBalls(bgaBalls); + + foreach (var bga in sorted) + { + double maxVoid = bga.Voids.Count > 0 ? bga.Voids.Max(v => v.AreaPercent) : 0; + // 额外判定:最大单个气泡占比超限也为NG + string cls = bga.Classification; + if (cls == "PASS" && maxVoid > MaxSingleVoidLimit) + cls = "FAIL"; + Results.Add(new BgaResultItem + { + Index = bga.Index, + Classification = cls, + CenterX = bga.CenterX.ToString("F1"), + CenterY = bga.CenterY.ToString("F1"), + BgaArea = bga.BgaArea.ToString(), + VoidRate = $"{bga.VoidRate:F1}%", + MaxVoidRate = $"{maxVoid:F1}%", + VoidCount = bga.Voids.Count.ToString(), + Circularity = bga.Circularity.ToString("F2") + }); + } + } + } + + // 绘制结果到图像 + ResultImage = RenderResults(grayImage, output); + + // 将结果图像推送到主界面显示 + if (ResultImage != null) + _viewportService?.SetManualImage(ResultImage, "BGA检测结果"); + + grayImage.Dispose(); + } + catch (Exception ex) + { + ResultText = $"错误: {ex.Message}"; + } + } + + private BitmapSource RenderResults(Image grayImage, IDictionary output) + { + if (!output.ContainsKey("BgaVoidResult")) return null; + + int bgaCount = (int)output["BgaCount"]; + if (bgaCount == 0) return null; + + double voidRate = (double)output["VoidRate"]; + string classification = (string)output["Classification"]; + double voidLimitVal = (double)output["VoidLimit"]; + int thickness = (int)output["Thickness"]; + var bgaBalls = output["BgaBalls"] as List; + + var colorImage = new Image(grayImage.Width, grayImage.Height); + CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr); + + if (bgaBalls != null && bgaBalls.Count > 0) + { + // 使用统一排序 + var sorted = SortBgaBalls(bgaBalls); + + // 半透明气泡填充 + var overlay = colorImage.Clone(); + foreach (var bga in sorted) + { + var fillColor = new MCvScalar(0, 200, 255); + foreach (var v in bga.Voids) + { + if (v.ContourPoints.Length > 0) + { + using var vop = new VectorOfPoint(v.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(overlay, vvop, 0, fillColor, -1); + } + } + } + CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage); + overlay.Dispose(); + + // 绘制焊球轮廓 + 编号(蓝色,焊球下方) + foreach (var bga in sorted) + { + // 应用最大单气泡限值判定 + double maxVoid = bga.Voids.Count > 0 ? bga.Voids.Max(v => v.AreaPercent) : 0; + string cls = bga.Classification; + if (cls == "PASS" && maxVoid > MaxSingleVoidLimit) + cls = "FAIL"; + + var bgaColor = cls == "PASS" + ? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255); + + if (bga.ContourPoints.Length > 0) + { + using var vop = new VectorOfPoint(bga.ContourPoints); + using var vvop = new VectorOfVectorOfPoint(vop); + CvInvoke.DrawContours(colorImage, vvop, 0, bgaColor, thickness); + } + + // 编号标注在焊球下方,蓝色字体 + var bbox = CvInvoke.BoundingRectangle(new VectorOfPoint(bga.ContourPoints)); + CvInvoke.PutText(colorImage, $"#{bga.Index}", + new System.Drawing.Point(bbox.X + bbox.Width / 2 - 10, bbox.Bottom + 16), + FontFace.HersheySimplex, 0.45, new MCvScalar(255, 100, 0), 2); + } + + // 左上角总览结果 + int ngCount = sorted.Count(b => + { + double mv = b.Voids.Count > 0 ? b.Voids.Max(v => v.AreaPercent) : 0; + return b.Classification == "FAIL" || mv > MaxSingleVoidLimit; + }); + int okCount = sorted.Count - ngCount; + var overallColor = ngCount > 0 + ? new MCvScalar(0, 0, 255) : new MCvScalar(0, 255, 0); + CvInvoke.PutText(colorImage, + $"Total: {sorted.Count} | OK: {okCount} | NG: {ngCount}", + new System.Drawing.Point(10, 25), + FontFace.HersheySimplex, 0.55, 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; + } + + /// 按行分组排序(Y相近归为一行,行内按X从左到右),重新编号 + private static List SortBgaBalls(List balls) + { + if (balls.Count == 0) return balls; + + // 计算平均焊球半径作为行间距容差 + var sortedByY = balls.OrderBy(b => b.CenterY).ToList(); + double avgRadius = balls.Average(b => Math.Sqrt(b.BgaArea / Math.PI)); + double rowTolerance = avgRadius * 0.8; + + var rows = new List>(); + var currentRow = new List { sortedByY[0] }; + + for (int i = 1; i < sortedByY.Count; i++) + { + if (sortedByY[i].CenterY - currentRow[0].CenterY > rowTolerance) + { + rows.Add(currentRow); + currentRow = new List(); + } + currentRow.Add(sortedByY[i]); + } + rows.Add(currentRow); + + // 每行内按X排序,汇总并重新编号 + var result = new List(); + foreach (var row in rows) + result.AddRange(row.OrderBy(b => b.CenterX)); + + for (int i = 0; i < result.Count; i++) + result[i].Index = i + 1; + + return result; + } + + 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 BgaResultItem + { + public int Index { get; set; } + public string Classification { get; set; } = ""; + public string CenterX { get; set; } = ""; + public string CenterY { get; set; } = ""; + public string BgaArea { get; set; } = ""; + public string VoidRate { get; set; } = ""; + public string MaxVoidRate { get; set; } = ""; + public string VoidCount { get; set; } = ""; + public string Circularity { get; set; } = ""; + } +} diff --git a/XplorePlane/Views/ImageProcessing/BgaDetectionPanel.xaml b/XplorePlane/Views/ImageProcessing/BgaDetectionPanel.xaml new file mode 100644 index 0000000..c56120a --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/BgaDetectionPanel.xaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +