Files
XplorePlane/XplorePlane/ViewModels/ImageProcessing/BgaDetectionViewModel.cs
T
wei.lw.li 38471bf510 feat: 改进BGA焊点和气泡检测算法,提升复杂背景下的检测精度
- 焊点检测:增加开运算去噪、闭运算改为1次减少粘连、提高默认圆度阈值0.6

- 新增焊球最小中心距过滤和面积一致性过滤,去除粘连和背景误检

- 气泡检测:新增自适应阈值,基于焊球内部灰度分布自动计算阈值

- BGA检测面板增加新参数控件:自适应阈值开关、阈值百分位、中心距、面积一致性
2026-07-21 13:31:51 +08:00

698 lines
30 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.Core;
using XP.ImageProcessing.Processors;
using XP.ImageProcessing.RoiControl.Controls;
using XP.ImageProcessing.RoiControl.Models;
using XplorePlane.Models;
using XplorePlane.Services.Dialogs;
using XplorePlane.Services.Main.Viewport;
using XplorePlane.ViewModels.Cnc;
using XP.Common.Logging.Interfaces;
namespace XplorePlane.ViewModels.ImageProcessing
{
public class BgaDetectionViewModel : BindableBase
{
private readonly IMainViewportService _viewportService;
private readonly IApplicationDialogService _dialogs;
private readonly ILoggerService _logger;
private CncEditorViewModel _cncEditorViewModel;
private System.Threading.CancellationTokenSource _debounceCts;
private const int DebounceMs = 300;
private const string BgaVoidRateOperatorKey = "BgaVoidRate";
public BgaDetectionViewModel(IMainViewportService viewportService, IApplicationDialogService dialogs = null, ILoggerService logger = null)
{
_viewportService = viewportService;
_dialogs = dialogs; // optional — 未提供时提示弹窗静默跳过
_logger = logger?.ForModule<BgaDetectionViewModel>(); // optional — 未提供时静默跳过日志
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.6;
public double Circularity { get => _circularity; set => SetProperty(ref _circularity, value); }
private int _bgaMinCenterDistance = 0;
public int BgaMinCenterDistance { get => _bgaMinCenterDistance; set => SetProperty(ref _bgaMinCenterDistance, value); }
private double _bgaAreaConsistency = 0.5;
public double BgaAreaConsistency { get => _bgaAreaConsistency; set => SetProperty(ref _bgaAreaConsistency, value); }
// BGA定位阈值参数
private int _bgaThresholdLow = 0;
public int BgaThresholdLow { get => _bgaThresholdLow; set => SetProperty(ref _bgaThresholdLow, value); }
private int _bgaThresholdHigh = 30720;
public int BgaThresholdHigh { get => _bgaThresholdHigh; set => SetProperty(ref _bgaThresholdHigh, value); }
// 气泡检测参数(16位范围 0-65535)
private int _minThreshold = 32768;
public int MinThreshold { get => _minThreshold; set => SetProperty(ref _minThreshold, value); }
private int _maxThreshold = 65535;
public int MaxThreshold { get => _maxThreshold; set => SetProperty(ref _maxThreshold, value); }
private bool _useAdaptiveThreshold = false;
public bool UseAdaptiveThreshold { get => _useAdaptiveThreshold; set => SetProperty(ref _useAdaptiveThreshold, value); }
private double _voidThresholdPercentile = 80.0;
public double VoidThresholdPercentile { get => _voidThresholdPercentile; set => SetProperty(ref _voidThresholdPercentile, 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 = "Cyan", 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()
{
// 每次执行都重新读取当前视口图像,避免使用过期的缓存图像
// (面板为单例窗口,若缓存首帧会导致重新采集后仍检测旧图)
var image = _viewportService?.CurrentDisplayImage as BitmapSource;
if (image == null)
{
ResultText = "请先加载图像";
return;
}
try
{
// grayImage 为 Emgu 非托管图像,用 using 确保异常路径下也能释放
using var grayImage = BitmapSourceToGray16(image);
var processor = new BgaVoidRateProcessor();
processor.SetParameter("BgaMinArea", BgaMinArea);
processor.SetParameter("BgaMaxArea", BgaMaxArea);
processor.SetParameter("BgaBlurSize", BlurSize);
processor.SetParameter("BgaCircularity", Circularity);
processor.SetParameter("BgaThresholdLow", BgaThresholdLow);
processor.SetParameter("BgaThresholdHigh", BgaThresholdHigh);
processor.SetParameter("BgaMinCenterDistance", BgaMinCenterDistance);
processor.SetParameter("BgaAreaConsistency", BgaAreaConsistency);
processor.SetParameter("UseAdaptiveThreshold", UseAdaptiveThreshold);
processor.SetParameter("VoidThresholdPercentile", VoidThresholdPercentile);
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);
}
}
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")
});
}
}
}
// 绘制结果到图像(渲染需要 8 位图)
using var grayImage8 = PixelDepthHelper.ToByteImage(grayImage);
ResultImage = RenderResults(grayImage8, output);
// 将结果叠加到 canvas overlay 上
ShowResultOnOverlay(ResultImage);
}
catch (Exception ex)
{
_logger?.Error(ex, "BGA void-rate detection failed");
ResultText = $"错误: {ex.Message}";
}
}
/// <summary>
/// 将当前 ROI 和算子参数插入到激活的 CNC 位置节点的检测模块中 BGA空洞模块的参数
/// </summary>
private void ExecuteInsertToCnc()
{
if (_cncEditorViewModel == null)
{
_dialogs?.Show("Detection_CncEditorNotReady", "Dialog_Info", ApplicationMessageKind.Warning);
return;
}
// 查找当前激活的检测模块节点(SelectedNode 本身是 InspectionModule,或其父节点是 SavePosition
var selectedNode = _cncEditorViewModel.SelectedNode;
CncNodeViewModel targetModuleNode = null;
if (selectedNode == null)
{
_dialogs?.Show("Detection_SelectNodeFirst", "Dialog_Info", ApplicationMessageKind.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)
{
_dialogs?.Show("Detection_NoActiveModule", "Dialog_Info", ApplicationMessageKind.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["BgaThresholdLow"] = BgaThresholdLow;
parameters["BgaThresholdHigh"] = BgaThresholdHigh;
parameters["BgaMinCenterDistance"] = BgaMinCenterDistance;
parameters["BgaAreaConsistency"] = BgaAreaConsistency;
parameters["UseAdaptiveThreshold"] = UseAdaptiveThreshold;
parameters["VoidThresholdPercentile"] = VoidThresholdPercentile;
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;
_dialogs?.Show("Detection_ParamsInserted", "Dialog_InsertSuccess", ApplicationMessageKind.Information, "BGA", targetModuleNode.Name);
}
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);
}
// 编号标注在焊球下方,蓝色字体
using var bboxVop = new VectorOfPoint(bga.ContourPoints);
var bbox = CvInvoke.BoundingRectangle(bboxVop);
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, ushort> BitmapSourceToGray16(BitmapSource bmp)
{
// Gray16 输入:直接提取 16 位数据,零精度损失
if (bmp.Format == PixelFormats.Gray16)
{
int w = bmp.PixelWidth, h = bmp.PixelHeight;
int stride = w * 2;
var pixels16 = new ushort[w * h];
bmp.CopyPixels(pixels16, stride, 0);
var gray = new Image<Gray, ushort>(w, h);
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
gray.Data[y, x, 0] = pixels16[y * w + x];
return gray;
}
// 其他格式(8位):先转 Gray8 再升位到 16 位(×256
var formatted = new FormatConvertedBitmap(bmp, PixelFormats.Gray8, null, 0);
int width = formatted.PixelWidth, height = formatted.PixelHeight;
int stride8 = width;
var pixels8 = new byte[height * stride8];
formatted.CopyPixels(pixels8, stride8, 0);
var result = new Image<Gray, ushort>(width, height);
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
result.Data[y, x, 0] = (ushort)Math.Round(pixels8[y * stride8 + x] / 255.0 * 65535.0);
return result;
}
}
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; } = "";
}
}