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; private System.Windows.Controls.Image _resultOverlayImage; 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(仅在添加/删除顶点时更新 Adorner,拖拽不触发) _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); // 订阅画布点击事件来添加 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 void ShowResultOnOverlay(BitmapSource resultBmp) { if (_canvas == null) return; // 移除旧的结果图层 RemoveResultOverlay(); if (resultBmp == null) return; // 创建新的结果图层叠加到 canvas 上(插入到背景图之后、ROI之前) _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) { // 插入到索引1(背景图是索引0),这样ROI和测量overlay在上面 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 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); // 将结果叠加到 canvas overlay 上 ShowResultOnOverlay(ResultImage); 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; } = ""; } }