649 lines
27 KiB
C#
649 lines
27 KiB
C#
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; } = "";
|
||
}
|
||
}
|