Files
XplorePlane/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs
T
2026-05-19 11:38:31 +08:00

649 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.Models;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels.Cnc;
namespace XplorePlane.ViewModels.ImageProcessing
{
public class BgaDetectionViewModel : BindableBase
{
private readonly IMainViewportService _viewportService;
private CncEditorViewModel _cncEditorViewModel;
private BitmapSource _originalImage;
private System.Threading.CancellationTokenSource _debounceCts;
private const int DebounceMs = 300;
private const string BgaVoidRateOperatorKey = "BgaVoidRate";
public BgaDetectionViewModel(IMainViewportService viewportService)
{
_viewportService = viewportService;
ExecuteCommand = new DelegateCommand(Execute);
InsertToCncCommand = new DelegateCommand(ExecuteInsertToCnc);
PropertyChanged += OnAnyPropertyChanged;
}
/// <summary>设置 CNC 编辑器 ViewModel 引用,用于插入参数到激活的 CNC 位置节点</summary>
public void SetCncEditorViewModel(CncEditorViewModel cncEditorViewModel)
{
_cncEditorViewModel = cncEditorViewModel;
}
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>();
_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;
}
}
/// <summary>智能插入顶点:找到距离新点最近的边,将新点插入到该边的两个顶点之间</summary>
private static void InsertPointToPolygon(Point newPoint, ObservableCollection<Point> 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<BgaResultItem> Results { get; } = new();
public DelegateCommand ExecuteCommand { get; }
public DelegateCommand InsertToCncCommand { 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<BgaBallInfo>;
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}";
}
}
/// <summary>
/// 将当前 ROI 和算子参数插入到激活的 CNC 位置节点的检测模块中 BGA空洞模块的参数
/// </summary>
private void ExecuteInsertToCnc()
{
if (_cncEditorViewModel == null)
{
MessageBox.Show("CNC 编辑器未就绪,无法插入参数。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 查找当前激活的检测模块节点(SelectedNode 本身是 InspectionModule,或其父节点是 SavePosition
var selectedNode = _cncEditorViewModel.SelectedNode;
CncNodeViewModel targetModuleNode = null;
if (selectedNode == null)
{
MessageBox.Show("请先在 CNC 编辑器中选择一个位置节点或检测模块。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (selectedNode.IsInspectionModule)
{
// 直接选中的是检测模块
targetModuleNode = selectedNode;
}
else if (selectedNode.IsSavePosition)
{
// 选中的是位置节点,查找其子节点中的检测模块
targetModuleNode = selectedNode.Children.FirstOrDefault(c => c.IsInspectionModule);
}
else
{
// 尝试在 Nodes 中找到当前选中节点所属的 SavePosition 的检测模块
var allNodes = _cncEditorViewModel.Nodes;
// 向前查找最近的 SavePosition
CncNodeViewModel ownerPosition = null;
foreach (var node in allNodes)
{
if (node.IsSavePosition)
ownerPosition = node;
if (node.Id == selectedNode.Id)
break;
}
if (ownerPosition != null)
targetModuleNode = ownerPosition.Children.FirstOrDefault(c => c.IsInspectionModule);
}
if (targetModuleNode == null)
{
MessageBox.Show("未找到激活的检测模块节点。\n请在 CNC 编辑器中选择一个包含检测模块的位置节点。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 获取或创建 Pipeline
var pipeline = targetModuleNode.Pipeline ?? new PipelineModel { Name = targetModuleNode.Name };
// 查找已有的 BgaVoidRate 算子节点
var bgaNode = pipeline.Nodes.FirstOrDefault(n =>
string.Equals(n.OperatorKey, BgaVoidRateOperatorKey, StringComparison.OrdinalIgnoreCase));
if (bgaNode == null)
{
// 不存在则新建一个 BgaVoidRate 节点并添加到流水线末尾
bgaNode = new PipelineNodeModel
{
Id = Guid.NewGuid(),
OperatorKey = BgaVoidRateOperatorKey,
Order = pipeline.Nodes.Count,
IsEnabled = true,
Parameters = new Dictionary<string, object>()
};
pipeline.Nodes.Add(bgaNode);
}
// 写入当前参数
var parameters = bgaNode.Parameters;
parameters["BgaMinArea"] = BgaMinArea;
parameters["BgaMaxArea"] = BgaMaxArea;
parameters["BgaBlurSize"] = BlurSize;
parameters["BgaCircularity"] = Circularity;
parameters["MinThreshold"] = MinThreshold;
parameters["MaxThreshold"] = MaxThreshold;
parameters["MinVoidArea"] = MinVoidArea;
parameters["VoidLimit"] = VoidLimit;
// 写入 ROI 参数
if (RoiEnabled && _roiShape != null && _roiShape.Points.Count >= 3)
{
parameters["RoiMode"] = "Polygon";
int count = Math.Min(_roiShape.Points.Count, 32);
parameters["PolyCount"] = count;
for (int i = 0; i < count; i++)
{
parameters[$"PolyX{i}"] = (int)_roiShape.Points[i].X;
parameters[$"PolyY{i}"] = (int)_roiShape.Points[i].Y;
}
}
else
{
parameters["RoiMode"] = "None";
parameters["PolyCount"] = 0;
}
// 更新 Pipeline 到节点
pipeline.UpdatedAt = DateTime.UtcNow;
targetModuleNode.Pipeline = pipeline;
// 强制刷新右侧检测模块面板:将选中节点切换到目标检测模块,触发重新加载
_cncEditorViewModel.SelectedNode = null;
_cncEditorViewModel.SelectedNode = targetModuleNode;
MessageBox.Show(
$"已将 BGA 检测参数插入到检测模块「{targetModuleNode.Name}」。",
"插入成功", MessageBoxButton.OK, MessageBoxImage.Information);
}
private BitmapSource RenderResults(Image<Gray, byte> grayImage, IDictionary<string, object> 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<BgaBallInfo>;
var colorImage = new Image<Bgr, byte>(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;
}
/// <summary>按行分组排序(Y相近归为一行,行内按X从左到右),重新编号</summary>
private static List<BgaBallInfo> SortBgaBalls(List<BgaBallInfo> 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<List<BgaBallInfo>>();
var currentRow = new List<BgaBallInfo> { sortedByY[0] };
for (int i = 1; i < sortedByY.Count; i++)
{
if (sortedByY[i].CenterY - currentRow[0].CenterY > rowTolerance)
{
rows.Add(currentRow);
currentRow = new List<BgaBallInfo>();
}
currentRow.Add(sortedByY[i]);
}
rows.Add(currentRow);
// 每行内按X排序,汇总并重新编号
var result = new List<BgaBallInfo>();
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<Gray, byte> 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<Gray, byte>(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; } = "";
}
}