已合并 PR 103: QFN检测模块

1.新增 ROI 对齐能力(RoiAlignmentProcessor + Alignment 基础类),打通模板位姿到 ROI 变换链路。
2.新增 QFN 一体检测算子(QfnAutoDetectionProcessor),串联模板匹配、ROI 对齐、中心/引脚空洞检测。
3.增强 QFN 相关 UI(新增 QFN 检测面板、ViewModel、图标、主界面入口与联动)。
4.改造 导航相机标定模块(新增采集服务接口/实现,标定控件重构,移除旧画布控件)。
5.优化部分 图像处理算子(如 Contrast/Threshold/GaussianBlur)与显示细节(线宽/线灰度自适应)。
This commit is contained in:
LI Wei.lw
2026-06-03 08:45:15 +08:00
43 changed files with 3846 additions and 441 deletions
+1
View File
@@ -29,6 +29,7 @@ bld/
[Ll]ogs/
lib/
XP.ImageProcessing/
XP.ImageProcessing.SmokeTest/
ImageProcessing.sln
# 排除 Libs 目录中的 DLL 和 PDB 文件(但保留目录结构)
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:cal="clr-namespace:XP.Camera.Calibration"
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
xmlns:roi="clr-namespace:XP.ImageProcessing.RoiControl.Controls;assembly=XP.ImageProcessing.RoiControl"
mc:Ignorable="d"
d:DesignHeight="850"
d:DesignWidth="1400">
@@ -88,6 +89,20 @@
<Image Source="/XP.Camera;component/Calibration/Resources/外部导入.png" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="采集当前点"
Command="{Binding CapturePointCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<Image Source="/XP.Camera;component/Calibration/Resources/执行.png" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="删除选中"
Command="{Binding DeleteSelectedPointCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<TextBlock Text="✕" FontSize="20" Foreground="Red" HorizontalAlignment="Center" />
</Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=CalibrationExecute}"
Command="{Binding CalibrateCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
@@ -113,6 +128,32 @@
VerticalAlignment="Center" FontFamily="Segoe UI"
IsChecked="{Binding ShowWorldCoordinates}"
Margin="10,0,0,0" FontSize="13" Foreground="{StaticResource TextColor}" />
<ToggleButton IsChecked="{Binding IsLiveView}" VerticalAlignment="Center" Margin="10,0,0,0"
Width="80" Height="66" Cursor="Hand" FontFamily="Segoe UI">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" Background="White" BorderBrush="#E1E1E1"
BorderThickness="1" CornerRadius="6">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock x:Name="Icon" Text="▶" FontSize="20" HorizontalAlignment="Center" Margin="0,2,0,3" />
<TextBlock x:Name="Label" Text="实时" FontSize="10.5" HorizontalAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Icon" Property="Text" Value="⏸" />
<Setter TargetName="Label" Property="Text" Value="冻结" />
<Setter TargetName="Bd" Property="Background" Value="#E8F5E9" />
<Setter TargetName="Bd" Property="BorderBrush" Value="#81C784" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#EAF2FB" />
<Setter TargetName="Bd" Property="BorderBrush" Value="#B0D4F1" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
</StackPanel>
</Border>
@@ -136,6 +177,7 @@
<DataGrid Grid.Row="1" AutoGenerateColumns="False" CanUserAddRows="True"
ItemsSource="{Binding CalibrationPoints}"
SelectedItem="{Binding SelectedPoint}"
HeadersVisibility="Column" GridLinesVisibility="All"
FontFamily="Segoe UI"
BorderBrush="{StaticResource BorderColor}" BorderThickness="1">
@@ -159,7 +201,8 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<controls:ImageCanvasControl Grid.Row="0" x:Name="imageCanvas" Margin="12,12,12,8" />
<roi:PolygonRoiCanvas Grid.Row="0" x:Name="roiCanvas" Margin="12,12,12,8"
ImageSource="{Binding ImageSource}" />
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
Margin="12,0,12,12" Padding="12" MinHeight="80">
@@ -1,10 +1,9 @@
using System.Drawing;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using XP.Camera.Calibration.ViewModels;
using WpfBrushes = System.Windows.Media.Brushes;
using WpfColor = System.Windows.Media.Color;
using XP.ImageProcessing.RoiControl.Controls;
namespace XP.Camera.Calibration.Controls;
@@ -23,82 +22,42 @@ public partial class CalibrationControl : UserControl
if (DataContext is CalibrationViewModel viewModel)
{
_viewModel = viewModel;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
}
_viewModel.ImageLoadedRequested += (s, e) =>
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
imageCanvas.ReferenceImage = _viewModel.ImageSource;
imageCanvas.RoiCanvas.Children.Clear();
if (e.PropertyName == nameof(CalibrationViewModel.OverlayImage))
{
UpdateDetectionOverlay();
}
}
private void UpdateDetectionOverlay()
{
if (_viewModel?.OverlayImage == null)
{
roiCanvas.ClearDetectionOverlay();
return;
}
var overlayCanvas = new Canvas
{
Width = _viewModel.OverlayImage.PixelWidth,
Height = _viewModel.OverlayImage.PixelHeight,
IsHitTestVisible = false
};
imageCanvas.CanvasRightMouseUp += ImageCanvas_RightMouseUp;
imageCanvas.CanvasMouseWheel += ImageCanvas_MouseWheel;
}
}
private void ImageCanvas_MouseWheel(object? sender, MouseWheelEventArgs e)
var image = new System.Windows.Controls.Image
{
if (_viewModel?.CurrentImage == null) return;
double zoom = e.Delta > 0 ? 1.1 : 0.9;
imageCanvas.ZoomScale *= zoom;
imageCanvas.ZoomScale = Math.Max(0.1, Math.Min(imageCanvas.ZoomScale, 10));
}
private void ImageCanvas_RightMouseUp(object? sender, MouseButtonEventArgs e)
{
if (_viewModel?.CurrentImage == null) return;
var pos = e.GetPosition(imageCanvas.RoiCanvas);
float imageX = (float)pos.X;
float imageY = (float)pos.Y;
if (imageX >= 0 && imageX < _viewModel.CurrentImage.Width &&
imageY >= 0 && imageY < _viewModel.CurrentImage.Height)
{
var pixelPoint = new PointF(imageX, imageY);
var worldPoint = _viewModel.ConvertPixelToWorld(pixelPoint);
_viewModel.StatusText = $"像素坐标: ({imageX:F2}, {imageY:F2})\n世界坐标: ({worldPoint.X:F2}, {worldPoint.Y:F2})";
DrawMarkerOnCanvas(imageX, imageY, worldPoint);
}
}
private void DrawMarkerOnCanvas(float imageX, float imageY, PointF worldPoint)
{
imageCanvas.RoiCanvas.Children.Clear();
var ellipse = new System.Windows.Shapes.Ellipse
{
Width = 10, Height = 10,
Stroke = WpfBrushes.Red, StrokeThickness = 2,
Fill = WpfBrushes.Transparent
Source = _viewModel.OverlayImage,
Width = _viewModel.OverlayImage.PixelWidth,
Height = _viewModel.OverlayImage.PixelHeight,
IsHitTestVisible = false
};
Canvas.SetLeft(ellipse, imageX - 5);
Canvas.SetTop(ellipse, imageY - 5);
imageCanvas.RoiCanvas.Children.Add(ellipse);
var pixelText = new TextBlock
{
Text = $"P:({imageX:F0},{imageY:F0})",
Foreground = WpfBrushes.Red, FontSize = 12,
Background = new System.Windows.Media.SolidColorBrush(WpfColor.FromArgb(180, 255, 255, 255))
};
Canvas.SetLeft(pixelText, imageX + 10);
Canvas.SetTop(pixelText, imageY - 20);
imageCanvas.RoiCanvas.Children.Add(pixelText);
if (_viewModel?.ShowWorldCoordinates == true)
{
var worldText = new TextBlock
{
Text = $"W:({worldPoint.X:F2},{worldPoint.Y:F2})",
Foreground = WpfBrushes.Blue, FontSize = 12,
Background = new System.Windows.Media.SolidColorBrush(WpfColor.FromArgb(180, 255, 255, 255))
};
Canvas.SetLeft(worldText, imageX + 10);
Canvas.SetTop(worldText, imageY + 5);
imageCanvas.RoiCanvas.Children.Add(worldText);
}
overlayCanvas.Children.Add(image);
roiCanvas.SetDetectionOverlayCanvas(overlayCanvas);
}
}
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:cal="clr-namespace:XP.Camera.Calibration"
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
xmlns:roi="clr-namespace:XP.ImageProcessing.RoiControl.Controls;assembly=XP.ImageProcessing.RoiControl"
mc:Ignorable="d"
d:DesignHeight="900"
d:DesignWidth="1600">
@@ -168,7 +169,8 @@
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<controls:ImageCanvasControl Grid.Row="0" x:Name="imageCanvas" Margin="12,12,8,8" />
<roi:PolygonRoiCanvas Grid.Row="0" x:Name="roiCanvas" Margin="12,12,8,8"
ImageSource="{Binding ImageSource}" />
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
Margin="12,0,8,12" Padding="12" Height="70">
<Grid>
@@ -1,46 +1,13 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using XP.Camera.Calibration.ViewModels;
namespace XP.Camera.Calibration.Controls;
public partial class ChessboardCalibrationControl : UserControl
{
private ChessboardCalibrationViewModel? _viewModel;
public ChessboardCalibrationControl()
{
InitializeComponent();
Loaded += ChessboardCalibrationControl_Loaded;
}
private void ChessboardCalibrationControl_Loaded(object sender, RoutedEventArgs e)
{
if (DataContext is ChessboardCalibrationViewModel viewModel)
{
_viewModel = viewModel;
_viewModel.ImageLoadedRequested += (s, e) =>
{
imageCanvas.ReferenceImage = _viewModel.ImageSource;
};
_viewModel.ImageClearedRequested += (s, e) =>
{
imageCanvas.ReferenceImage = null;
};
imageCanvas.CanvasMouseWheel += ImageCanvas_MouseWheel;
}
}
private void ImageCanvas_MouseWheel(object? sender, MouseWheelEventArgs e)
{
if (_viewModel?.ImageSource == null) return;
double zoom = e.Delta > 0 ? 1.1 : 0.9;
imageCanvas.ZoomScale *= zoom;
imageCanvas.ZoomScale = Math.Max(0.1, Math.Min(imageCanvas.ZoomScale, 10));
}
}
@@ -1,33 +0,0 @@
<UserControl x:Class="XP.Camera.Calibration.Controls.ImageCanvasControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="800" x:Name="imageCanvasControl">
<Border ClipToBounds="True" RenderOptions.BitmapScalingMode="NearestNeighbor">
<Viewbox>
<AdornerDecorator x:Name="adorner" MouseWheel="Adorner_MouseWheel">
<AdornerDecorator.RenderTransform>
<TransformGroup>
<TranslateTransform X="{Binding PanningOffsetX, ElementName=imageCanvasControl}"
Y="{Binding PanningOffsetY, ElementName=imageCanvasControl}" />
<ScaleTransform ScaleX="{Binding ZoomScale, ElementName=imageCanvasControl}"
ScaleY="{Binding ZoomScale, ElementName=imageCanvasControl}"
CenterX="{Binding ZoomCenter.X, ElementName=imageCanvasControl}"
CenterY="{Binding ZoomCenter.Y, ElementName=imageCanvasControl}" />
</TransformGroup>
</AdornerDecorator.RenderTransform>
<Grid PreviewMouseMove="Canvas_MouseMove"
PreviewMouseLeftButtonUp="Canvas_MouseLeftButtonUp"
PreviewMouseRightButtonUp="Canvas_MouseRightButtonUp"
MouseEnter="Canvas_MouseEnter"
PreviewMouseLeftButtonDown="Canvas_MouseLeftButtonDown"
PreviewMouseRightButtonDown="Canvas_MouseRightButtonDown">
<ContentPresenter Content="{Binding RoiCanvas, ElementName=imageCanvasControl}"
SizeChanged="ContentPresenter_SizeChanged" />
</Grid>
</AdornerDecorator>
</Viewbox>
</Border>
</UserControl>
@@ -1,229 +0,0 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace XP.Camera.Calibration.Controls;
/// <summary>
/// 图像画布控件 - 提供图像显示、缩放、平移功能
/// </summary>
public partial class ImageCanvasControl : UserControl
{
private Point mouseDownPoint = new Point();
#region Dependency Properties
public static readonly DependencyProperty ZoomScaleProperty =
DependencyProperty.Register("ZoomScale", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(1.0));
public static readonly DependencyProperty ZoomCenterProperty =
DependencyProperty.Register("ZoomCenter", typeof(Point), typeof(ImageCanvasControl), new PropertyMetadata(new Point()));
public static readonly DependencyProperty PanningOffsetXProperty =
DependencyProperty.Register("PanningOffsetX", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(0.0));
public static readonly DependencyProperty PanningOffsetYProperty =
DependencyProperty.Register("PanningOffsetY", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(0.0));
public static readonly DependencyProperty ReferenceImageProperty =
DependencyProperty.Register("ReferenceImage", typeof(BitmapSource), typeof(ImageCanvasControl),
new UIPropertyMetadata(null, ReferenceImageChanged));
public static readonly DependencyProperty ImageScaleFactorProperty =
DependencyProperty.Register("ImageScaleFactor", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(1.0));
public static readonly DependencyProperty MaxImageWidthProperty =
DependencyProperty.Register("MaxImageWidth", typeof(int), typeof(ImageCanvasControl), new PropertyMetadata(0));
public static readonly DependencyProperty MaxImageHeightProperty =
DependencyProperty.Register("MaxImageHeight", typeof(int), typeof(ImageCanvasControl), new PropertyMetadata(0));
public static readonly DependencyProperty EnablePanningProperty =
DependencyProperty.Register("EnablePanning", typeof(bool), typeof(ImageCanvasControl), new PropertyMetadata(true));
#endregion
#region Properties
public double ZoomScale
{
get => (double)GetValue(ZoomScaleProperty);
set => SetValue(ZoomScaleProperty, value);
}
public Point ZoomCenter
{
get => (Point)GetValue(ZoomCenterProperty);
set => SetValue(ZoomCenterProperty, value);
}
public double PanningOffsetX
{
get => (double)GetValue(PanningOffsetXProperty);
set => SetValue(PanningOffsetXProperty, value);
}
public double PanningOffsetY
{
get => (double)GetValue(PanningOffsetYProperty);
set => SetValue(PanningOffsetYProperty, value);
}
public BitmapSource? ReferenceImage
{
get => (BitmapSource?)GetValue(ReferenceImageProperty);
set => SetValue(ReferenceImageProperty, value);
}
public double ImageScaleFactor
{
get => (double)GetValue(ImageScaleFactorProperty);
set => SetValue(ImageScaleFactorProperty, value);
}
public int MaxImageWidth
{
get => (int)GetValue(MaxImageWidthProperty);
set => SetValue(MaxImageWidthProperty, value);
}
public int MaxImageHeight
{
get => (int)GetValue(MaxImageHeightProperty);
set => SetValue(MaxImageHeightProperty, value);
}
public bool EnablePanning
{
get => (bool)GetValue(EnablePanningProperty);
set => SetValue(EnablePanningProperty, value);
}
private Canvas roiCanvas = new Canvas();
public Canvas RoiCanvas
{
get => roiCanvas;
set => roiCanvas = value;
}
#endregion
#region Events
public event EventHandler<MouseButtonEventArgs>? CanvasRightMouseUp;
public event EventHandler<MouseButtonEventArgs>? CanvasRightMouseDown;
public event EventHandler<MouseButtonEventArgs>? CanvasLeftMouseDown;
public event EventHandler<MouseEventArgs>? CanvasMouseMove;
public event EventHandler<MouseWheelEventArgs>? CanvasMouseWheel;
#endregion
public ImageCanvasControl()
{
InitializeComponent();
}
#region Private Methods
private static void ReferenceImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
(d as ImageCanvasControl)?.OnReferenceImageChanged(e.NewValue as BitmapSource);
}
private void OnReferenceImageChanged(BitmapSource? bitmapSource)
{
if (bitmapSource != null)
{
ImageBrush brush = new ImageBrush { ImageSource = bitmapSource, Stretch = Stretch.Uniform };
RoiCanvas.Background = brush;
RoiCanvas.Height = bitmapSource.Height;
RoiCanvas.Width = bitmapSource.Width;
}
else
{
RoiCanvas.Height = MaxImageHeight > 0 ? MaxImageHeight : 600;
RoiCanvas.Width = MaxImageWidth > 0 ? MaxImageWidth : 800;
RoiCanvas.Background = Brushes.LightGray;
}
}
private double CalculateScaleFactor()
{
if (ActualWidth <= 0) return 1;
double scaleFactor = Math.Max(RoiCanvas.Width / ActualWidth, RoiCanvas.Height / ActualHeight);
if (scaleFactor < 0)
scaleFactor = Math.Min(RoiCanvas.Width / ActualWidth, RoiCanvas.Height / ActualHeight);
return scaleFactor;
}
#endregion
#region Event Handlers
private void Canvas_MouseEnter(object sender, MouseEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
mouseDownPoint = e.GetPosition(RoiCanvas);
}
private void Canvas_MouseMove(object sender, MouseEventArgs e)
{
CanvasMouseMove?.Invoke(sender, e);
if (EnablePanning && e.LeftButton == MouseButtonState.Pressed)
{
Point mousePoint = e.GetPosition(RoiCanvas);
double mouseMoveLength = Point.Subtract(mousePoint, mouseDownPoint).Length;
if (mouseMoveLength > (10 * CalculateScaleFactor()) / ZoomScale)
{
PanningOffsetX += mousePoint.X - mouseDownPoint.X;
PanningOffsetY += mousePoint.Y - mouseDownPoint.Y;
}
}
}
private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { }
private void Canvas_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
CanvasRightMouseUp?.Invoke(sender, e);
}
private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
mouseDownPoint = e.GetPosition(RoiCanvas);
CanvasLeftMouseDown?.Invoke(sender, e);
if (EnablePanning && e.ClickCount == 2)
{
ResetView();
e.Handled = true;
}
}
private void Canvas_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
CanvasRightMouseDown?.Invoke(sender, e);
e.Handled = true;
}
private void Adorner_MouseWheel(object sender, MouseWheelEventArgs e)
{
CanvasMouseWheel?.Invoke(sender, e);
}
private void ContentPresenter_SizeChanged(object sender, SizeChangedEventArgs e)
{
ImageScaleFactor = CalculateScaleFactor();
}
private void ResetView()
{
ZoomScale = 1.0;
PanningOffsetX = 0.0;
PanningOffsetY = 0.0;
ZoomCenter = new Point(0, 0);
}
#endregion
}
@@ -0,0 +1,73 @@
using System.Drawing;
using System.Windows.Media.Imaging;
namespace XP.Camera.Calibration;
/// <summary>
/// 标定采集服务接口
/// 提供"一键采集"能力:读取编码器坐标 + 拍图 + 识别标记中心
/// </summary>
public interface ICalibrationCaptureService
{
/// <summary>是否可用(相机已连接、运动系统就绪)</summary>
bool IsAvailable { get; }
/// <summary>
/// 采集当前标定点
/// </summary>
/// <returns>采集结果,失败时返回 null</returns>
CaptureResult? CaptureCurrentPoint();
/// <summary>
/// 获取当前导航相机图像
/// </summary>
BitmapSource? CaptureImage();
/// <summary>
/// 启动实时预览(将相机实时画面推送到 LiveImageUpdated 事件)
/// </summary>
void StartLivePreview();
/// <summary>
/// 停止实时预览
/// </summary>
void StopLivePreview();
/// <summary>
/// 实时画面更新事件
/// </summary>
event EventHandler<LiveImageEventArgs>? LiveImageUpdated;
}
/// <summary>
/// 实时画面事件参数
/// </summary>
public class LiveImageEventArgs : EventArgs
{
public BitmapSource Image { get; }
public LiveImageEventArgs(BitmapSource image) => Image = image;
}
/// <summary>
/// 单次采集结果
/// </summary>
public class CaptureResult
{
/// <summary>标记中心像素坐标 X(亚像素)</summary>
public double PixelX { get; set; }
/// <summary>标记中心像素坐标 Y(亚像素)</summary>
public double PixelY { get; set; }
/// <summary>平台编码器坐标 X (mm)</summary>
public double WorldX { get; set; }
/// <summary>平台编码器坐标 Y (mm)</summary>
public double WorldY { get; set; }
/// <summary>采集的图像</summary>
public BitmapSource? Image { get; set; }
/// <summary>检测到的标记轮廓点集</summary>
public System.Drawing.Point[]? ContourPoints { get; set; }
}
@@ -11,16 +11,20 @@ namespace XP.Camera.Calibration.ViewModels;
public class CalibrationViewModel : BindableBase
{
private readonly ICalibrationDialogService _dialogService;
private readonly ICalibrationCaptureService? _captureService;
private readonly CalibrationProcessor _calibrator = new();
private Image<Bgr, byte>? _currentImage;
private static readonly ILogger _logger = Log.ForContext<CalibrationViewModel>();
private BitmapSource? _imageSource;
private BitmapSource? _frozenImage;
private string _statusText = Res.CalibrationStatusReady;
private bool _showWorldCoordinates;
private bool _isLiveView = true;
public CalibrationViewModel(ICalibrationDialogService dialogService)
public CalibrationViewModel(ICalibrationDialogService dialogService, ICalibrationCaptureService? captureService = null)
{
_dialogService = dialogService;
_captureService = captureService;
CalibrationPoints = new ObservableCollection<CalibrationProcessor.CalibrationPoint>();
LoadImageCommand = new DelegateCommand(LoadImage);
@@ -29,6 +33,16 @@ public class CalibrationViewModel : BindableBase
.ObservesProperty(() => CalibrationPoints.Count);
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
LoadCalibrationCommand = new DelegateCommand(LoadCalibration);
CapturePointCommand = new DelegateCommand(CapturePoint, CanCapturePoint);
DeleteSelectedPointCommand = new DelegateCommand(DeleteSelectedPoint, () => SelectedPoint != null)
.ObservesProperty(() => SelectedPoint);
// 启动实时预览
if (_captureService != null)
{
_captureService.LiveImageUpdated += OnLiveImageUpdated;
_captureService.StartLivePreview();
}
}
public ObservableCollection<CalibrationProcessor.CalibrationPoint> CalibrationPoints { get; }
@@ -39,6 +53,14 @@ public class CalibrationViewModel : BindableBase
set => SetProperty(ref _imageSource, value);
}
private BitmapSource? _overlayImage;
/// <summary>叠加层图像(显示检测到的轮廓和中心点)</summary>
public BitmapSource? OverlayImage
{
get => _overlayImage;
set => SetProperty(ref _overlayImage, value);
}
public string StatusText
{
get => _statusText;
@@ -51,11 +73,48 @@ public class CalibrationViewModel : BindableBase
set => SetProperty(ref _showWorldCoordinates, value);
}
public bool IsLiveView
{
get => _isLiveView;
set
{
if (SetProperty(ref _isLiveView, value))
{
RaisePropertyChanged(nameof(LiveViewButtonText));
if (value)
{
// 切回实时:恢复实时预览
_captureService?.StartLivePreview();
}
else
{
// 切到当前:冻结当前帧
_frozenImage = _imageSource;
_captureService?.StopLivePreview();
}
}
}
}
public string LiveViewButtonText => _isLiveView ? "⏸ 冻结" : "▶ 实时";
public DelegateCommand LoadImageCommand { get; }
public DelegateCommand LoadCsvCommand { get; }
public DelegateCommand CalibrateCommand { get; }
public DelegateCommand SaveCalibrationCommand { get; }
public DelegateCommand LoadCalibrationCommand { get; }
public DelegateCommand CapturePointCommand { get; }
public DelegateCommand DeleteSelectedPointCommand { get; }
/// <summary>是否支持采集模式(有采集服务注入)</summary>
public bool IsCaptureAvailable => _captureService?.IsAvailable == true;
private CalibrationProcessor.CalibrationPoint? _selectedPoint;
public CalibrationProcessor.CalibrationPoint? SelectedPoint
{
get => _selectedPoint;
set => SetProperty(ref _selectedPoint, value);
}
private void LoadImage()
{
@@ -130,6 +189,59 @@ public class CalibrationViewModel : BindableBase
}
}
private bool CanCapturePoint() => _captureService?.IsAvailable == true;
private void CapturePoint()
{
if (_captureService == null) return;
try
{
var result = _captureService.CaptureCurrentPoint();
if (result == null)
{
StatusText = "采集失败:未能识别标记点,请确认标记在视野内";
return;
}
CalibrationPoints.Add(new CalibrationProcessor.CalibrationPoint
{
PixelX = result.PixelX,
PixelY = result.PixelY,
WorldX = result.WorldX,
WorldY = result.WorldY
});
// 更新图像显示
if (result.Image != null)
{
ImageSource = result.Image;
}
// 绘制检测结果叠加层(轮廓 + 中心点)
DrawDetectionOverlay(result);
StatusText = $"已采集第 {CalibrationPoints.Count} 个点: 像素({result.PixelX:F1}, {result.PixelY:F1}) → 物理({result.WorldX:F3}, {result.WorldY:F3})";
_logger.Information("标定点采集: Pixel=({PixelX:F1}, {PixelY:F1}), World=({WorldX:F3}, {WorldY:F3})",
result.PixelX, result.PixelY, result.WorldX, result.WorldY);
}
catch (Exception ex)
{
StatusText = $"采集异常: {ex.Message}";
_logger.Error(ex, "标定点采集失败");
}
}
private void DeleteSelectedPoint()
{
if (SelectedPoint != null && CalibrationPoints.Contains(SelectedPoint))
{
CalibrationPoints.Remove(SelectedPoint);
SelectedPoint = null;
StatusText = $"已删除,剩余 {CalibrationPoints.Count} 个标定点";
}
}
public PointF ConvertPixelToWorld(PointF pixel) => _calibrator.PixelToWorld(pixel);
public Image<Bgr, byte>? CurrentImage => _currentImage;
@@ -138,6 +250,82 @@ public class CalibrationViewModel : BindableBase
private void RaiseEvent(EventHandler? handler) => handler?.Invoke(this, EventArgs.Empty);
private void OnLiveImageUpdated(object? sender, LiveImageEventArgs e)
{
if (!_isLiveView) return;
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
{
ImageSource = e.Image;
});
}
/// <summary>
/// 绘制检测结果叠加层(轮廓 + 中心十字 + 坐标文字)
/// </summary>
private void DrawDetectionOverlay(CaptureResult result)
{
if (result.Image == null) return;
int w = result.Image.PixelWidth;
int h = result.Image.PixelHeight;
// 创建透明叠加层
var visual = new System.Windows.Media.DrawingVisual();
using (var dc = visual.RenderOpen())
{
// 绘制轮廓
if (result.ContourPoints != null && result.ContourPoints.Length > 2)
{
var pen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Lime, 2);
var geometry = new System.Windows.Media.StreamGeometry();
using (var ctx = geometry.Open())
{
ctx.BeginFigure(new System.Windows.Point(result.ContourPoints[0].X, result.ContourPoints[0].Y), false, true);
for (int i = 1; i < result.ContourPoints.Length; i++)
ctx.LineTo(new System.Windows.Point(result.ContourPoints[i].X, result.ContourPoints[i].Y), true, false);
}
geometry.Freeze();
dc.DrawGeometry(null, pen, geometry);
}
// 绘制中心十字
double cx = result.PixelX;
double cy = result.PixelY;
double crossSize = Math.Max(10, Math.Max(w, h) / 80.0);
var crossPen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Red, 2);
dc.DrawLine(crossPen, new System.Windows.Point(cx - crossSize, cy), new System.Windows.Point(cx + crossSize, cy));
dc.DrawLine(crossPen, new System.Windows.Point(cx, cy - crossSize), new System.Windows.Point(cx, cy + crossSize));
// 绘制坐标文字
var text = new System.Windows.Media.FormattedText(
$"({result.PixelX:F1}, {result.PixelY:F1})",
System.Globalization.CultureInfo.CurrentCulture,
System.Windows.FlowDirection.LeftToRight,
new System.Windows.Media.Typeface("Segoe UI"),
Math.Max(12, Math.Max(w, h) / 60.0),
System.Windows.Media.Brushes.Yellow,
1.0);
dc.DrawText(text, new System.Windows.Point(cx + crossSize + 4, cy - text.Height / 2));
}
var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(w, h, 96, 96, System.Windows.Media.PixelFormats.Pbgra32);
rtb.Render(visual);
rtb.Freeze();
OverlayImage = rtb;
}
/// <summary>
/// 停止实时预览并清理资源(窗口关闭时调用)
/// </summary>
public void Cleanup()
{
if (_captureService != null)
{
_captureService.StopLivePreview();
_captureService.LiveImageUpdated -= OnLiveImageUpdated;
}
}
private static BitmapSource MatToBitmapSource(Mat mat)
{
using var bitmap = mat.ToBitmap();
+1
View File
@@ -26,6 +26,7 @@
<PackageReference Include="Prism.Wpf" Version="9.0.537" />
<PackageReference Include="Serilog" Version="4.3.1" />
<ProjectReference Include="..\XP.Common\XP.Common.csproj" />
<ProjectReference Include="..\XP.ImageProcessing.RoiControl\XP.ImageProcessing.RoiControl.csproj" />
</ItemGroup>
<ItemGroup>
+148
View File
@@ -1551,6 +1551,50 @@
<value>Path to a pre-trained model file (.tmmodel). If it exists the model is loaded directly; otherwise the template is learned and the model is saved automatically.</value>
</data>
<!-- RoiAlignmentProcessor -->
<data name="RoiAlignmentProcessor_Name" xml:space="preserve">
<value>ROI Alignment</value>
</data>
<data name="RoiAlignmentProcessor_Description" xml:space="preserve">
<value>Transform teach polygon ROI to the run image using reference and template-match poses (outputs Poly params for downstream inspectors).</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX" xml:space="preserve">
<value>Reference center X</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX_Desc" xml:space="preserve">
<value>Template/part center X on the teach image (pixels).</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY" xml:space="preserve">
<value>Reference center Y</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY_Desc" xml:space="preserve">
<value>Template/part center Y on the teach image (pixels).</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle" xml:space="preserve">
<value>Reference angle (°)</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle_Desc" xml:space="preserve">
<value>Reference angle on the teach image; usually 0.</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX" xml:space="preserve">
<value>Measured center X</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX_Desc" xml:space="preserve">
<value>Match center X on the current image; inject from previous step OutputData.</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY" xml:space="preserve">
<value>Measured center Y</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY_Desc" xml:space="preserve">
<value>Match center Y on the current image.</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle" xml:space="preserve">
<value>Measured angle (°)</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle_Desc" xml:space="preserve">
<value>Match angle on the current image.</value>
</data>
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
<value>Angle Measurement</value>
</data>
@@ -2008,4 +2052,108 @@ Reprojection error: {1:F4} pixels</value>
<value>Histogram — No data</value>
<comment>ImageHistogramControl - Placeholder text when no image data</comment>
</data>
<!-- QfnLeadPadVoidProcessor -->
<data name="QfnLeadPadVoidProcessor_Name" xml:space="preserve">
<value>QFN Lead Pad Void Detection</value>
</data>
<data name="QfnLeadPadVoidProcessor_Description" xml:space="preserve">
<value>Automatically detect QFN lead pads and measure void rate per pad (two-step: locate pads → detect voids)</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode" xml:space="preserve">
<value>ROI Mode</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode_Desc" xml:space="preserve">
<value>None: Full image; Polygon: Polygon ROI (select lead pad area)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize" xml:space="preserve">
<value>Pad Blur Size</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize_Desc" xml:space="preserve">
<value>Gaussian blur kernel size for pad detection (odd number)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow" xml:space="preserve">
<value>Pad Threshold Low</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow_Desc" xml:space="preserve">
<value>Lower gray threshold for pad segmentation (pads are dark regions)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh" xml:space="preserve">
<value>Pad Threshold High</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh_Desc" xml:space="preserve">
<value>Upper gray threshold for pad segmentation</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel" xml:space="preserve">
<value>Morph Kernel Size</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel_Desc" xml:space="preserve">
<value>Closing kernel size to fill small holes inside pads</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea" xml:space="preserve">
<value>Min Pad Area</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea_Desc" xml:space="preserve">
<value>Minimum pixel area to be recognized as a lead pad (filter noise)</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea" xml:space="preserve">
<value>Max Pad Area</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea_Desc" xml:space="preserve">
<value>Maximum pixel area for a lead pad (exclude thermal pad)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin" xml:space="preserve">
<value>Min Aspect Ratio</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc" xml:space="preserve">
<value>Minimum aspect ratio for lead pads (QFN pads are elongated, ratio &gt; 1)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow" xml:space="preserve">
<value>Void Threshold Low</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow_Desc" xml:space="preserve">
<value>Lower gray threshold for void detection (voids are bright regions)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh" xml:space="preserve">
<value>Void Threshold High</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc" xml:space="preserve">
<value>Upper gray threshold for void detection</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea" xml:space="preserve">
<value>Min Void Area</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea_Desc" xml:space="preserve">
<value>Areas smaller than this are treated as noise (pixels)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius" xml:space="preserve">
<value>Void Merge Radius</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius_Desc" xml:space="preserve">
<value>Dilation radius to merge adjacent voids (0 = no merge)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit" xml:space="preserve">
<value>Void Rate Limit (%)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit_Desc" xml:space="preserve">
<value>Max allowed void rate per lead pad (default 50%, ref IPC-7095)</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea" xml:space="preserve">
<value>Min Qualified Pad Area</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc" xml:space="preserve">
<value>Pads with area below this value are marked as FAIL (insufficient solder)</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness" xml:space="preserve">
<value>Thickness</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness_Desc" xml:space="preserve">
<value>Contour drawing line thickness</value>
</data>
<data name="QfnAutoDetectionProcessor_Name" xml:space="preserve">
<value>QFN Integrated Detection</value>
</data>
<data name="QfnAutoDetectionProcessor_Description" xml:space="preserve">
<value>After template-match alignment, automatically run center pad void and lead pad void inspections and aggregate the final classification.</value>
</data>
</root>
+148
View File
@@ -1573,6 +1573,50 @@
<value>已训练模型文件路径(.tmmodel)。若存在则直接加载跳过学习;若不存在则从模板学习后自动保存。</value>
</data>
<!-- RoiAlignmentProcessor -->
<data name="RoiAlignmentProcessor_Name" xml:space="preserve">
<value>ROI对齐</value>
</data>
<data name="RoiAlignmentProcessor_Description" xml:space="preserve">
<value>按示教位姿与模板匹配位姿,将示教多边形 ROI 变换到运行图(输出 Poly 参数供下游检测算子使用)。</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX" xml:space="preserve">
<value>基准中心X</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX_Desc" xml:space="preserve">
<value>示教图上的模板/器件中心 X(像素)。</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY" xml:space="preserve">
<value>基准中心Y</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY_Desc" xml:space="preserve">
<value>示教图上的模板/器件中心 Y(像素)。</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle" xml:space="preserve">
<value>基准角度(°)</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle_Desc" xml:space="preserve">
<value>示教图上的基准角度,通常为 0。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX" xml:space="preserve">
<value>测量中心X</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX_Desc" xml:space="preserve">
<value>当前图上模板匹配中心 X,由流水线从上一步 OutputData 注入。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY" xml:space="preserve">
<value>测量中心Y</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY_Desc" xml:space="preserve">
<value>当前图上模板匹配中心 Y。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle" xml:space="preserve">
<value>测量角度(°)</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle_Desc" xml:space="preserve">
<value>当前图上模板匹配角度。</value>
</data>
<!-- AngleMeasurementProcessor -->
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
<value>角度测量</value>
@@ -2041,4 +2085,108 @@
<value>直方图 — 暂无数据</value>
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
</data>
<!-- QfnLeadPadVoidProcessor -->
<data name="QfnLeadPadVoidProcessor_Name" xml:space="preserve">
<value>QFN引脚空洞率检测</value>
</data>
<data name="QfnLeadPadVoidProcessor_Description" xml:space="preserve">
<value>自动检测QFN引脚焊点并逐引脚测量空洞率(两步法:定位引脚 → 检测空洞)</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode" xml:space="preserve">
<value>ROI模式</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode_Desc" xml:space="preserve">
<value>None: 全图检测; Polygon: 多边形ROI(框选引脚区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize" xml:space="preserve">
<value>引脚模糊核</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize_Desc" xml:space="preserve">
<value>引脚定位时的高斯模糊核大小(奇数)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow" xml:space="preserve">
<value>引脚阈值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow_Desc" xml:space="preserve">
<value>引脚分割灰度下限(焊点为暗区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh" xml:space="preserve">
<value>引脚阈值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh_Desc" xml:space="preserve">
<value>引脚分割灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel" xml:space="preserve">
<value>形态学核大小</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel_Desc" xml:space="preserve">
<value>闭运算核大小,用于填充引脚内部小孔洞</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea" xml:space="preserve">
<value>引脚最小面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea_Desc" xml:space="preserve">
<value>识别为引脚焊点的最小像素面积(过滤噪声)</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea" xml:space="preserve">
<value>引脚最大面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea_Desc" xml:space="preserve">
<value>识别为引脚焊点的最大像素面积(排除散热焊盘)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin" xml:space="preserve">
<value>最小长宽比</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc" xml:space="preserve">
<value>引脚最小长宽比(QFN引脚为长条形,长宽比&gt;1</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow" xml:space="preserve">
<value>空洞阈值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow_Desc" xml:space="preserve">
<value>空洞检测灰度下限(空洞为亮区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh" xml:space="preserve">
<value>空洞阈值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc" xml:space="preserve">
<value>空洞检测灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea" xml:space="preserve">
<value>最小空洞面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea_Desc" xml:space="preserve">
<value>小于此面积的区域视为噪点忽略(像素)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius" xml:space="preserve">
<value>空洞合并半径</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius_Desc" xml:space="preserve">
<value>相邻空洞合并的膨胀半径(0=不合并)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit" xml:space="preserve">
<value>空洞率限值(%)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit_Desc" xml:space="preserve">
<value>单引脚最大允许空洞率(默认50%,参考IPC-7095</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea" xml:space="preserve">
<value>引脚合格面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc" xml:space="preserve">
<value>引脚面积低于此值判定为不合格(焊料不足)</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness" xml:space="preserve">
<value>线条粗细</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness_Desc" xml:space="preserve">
<value>轮廓绘制线条粗细</value>
</data>
<data name="QfnAutoDetectionProcessor_Name" xml:space="preserve">
<value>QFN一体检测</value>
</data>
<data name="QfnAutoDetectionProcessor_Description" xml:space="preserve">
<value>模板匹配对齐后,自动完成中心焊盘空洞与引脚空洞检测并汇总判定</value>
</data>
</root>
+148
View File
@@ -1545,6 +1545,50 @@
<value>未找到 TemplateMatchLib.dll,请先编译 C++ DLL 工程。</value>
</data>
<!-- RoiAlignmentProcessor -->
<data name="RoiAlignmentProcessor_Name" xml:space="preserve">
<value>ROI对齐</value>
</data>
<data name="RoiAlignmentProcessor_Description" xml:space="preserve">
<value>按示教位姿与模板匹配位姿,将示教多边形 ROI 变换到运行图(输出 Poly 参数供下游检测算子使用)。</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX" xml:space="preserve">
<value>基准中心X</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterX_Desc" xml:space="preserve">
<value>示教图上的模板/器件中心 X(像素)。</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY" xml:space="preserve">
<value>基准中心Y</value>
</data>
<data name="RoiAlignmentProcessor_RefCenterY_Desc" xml:space="preserve">
<value>示教图上的模板/器件中心 Y(像素)。</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle" xml:space="preserve">
<value>基准角度(°)</value>
</data>
<data name="RoiAlignmentProcessor_RefAngle_Desc" xml:space="preserve">
<value>示教图上的基准角度,通常为 0。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX" xml:space="preserve">
<value>测量中心X</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterX_Desc" xml:space="preserve">
<value>当前图上模板匹配中心 X,由流水线从上一步 OutputData 注入。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY" xml:space="preserve">
<value>测量中心Y</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredCenterY_Desc" xml:space="preserve">
<value>当前图上模板匹配中心 Y。</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle" xml:space="preserve">
<value>测量角度(°)</value>
</data>
<data name="RoiAlignmentProcessor_MeasuredAngle_Desc" xml:space="preserve">
<value>当前图上模板匹配角度。</value>
</data>
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
<value>角度测量</value>
</data>
@@ -2002,4 +2046,108 @@
<value>直方图 — 暂无数据</value>
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
</data>
<!-- QfnLeadPadVoidProcessor -->
<data name="QfnLeadPadVoidProcessor_Name" xml:space="preserve">
<value>QFN引脚空洞率检测</value>
</data>
<data name="QfnLeadPadVoidProcessor_Description" xml:space="preserve">
<value>自动检测QFN引脚焊点并逐引脚测量空洞率(两步法:定位引脚 → 检测空洞)</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode" xml:space="preserve">
<value>ROI模式</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode_Desc" xml:space="preserve">
<value>None: 全图检测; Polygon: 多边形ROI(框选引脚区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize" xml:space="preserve">
<value>引脚模糊核</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize_Desc" xml:space="preserve">
<value>引脚定位时的高斯模糊核大小(奇数)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow" xml:space="preserve">
<value>引脚阈值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow_Desc" xml:space="preserve">
<value>引脚分割灰度下限(焊点为暗区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh" xml:space="preserve">
<value>引脚阈值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh_Desc" xml:space="preserve">
<value>引脚分割灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel" xml:space="preserve">
<value>形态学核大小</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel_Desc" xml:space="preserve">
<value>闭运算核大小,用于填充引脚内部小孔洞</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea" xml:space="preserve">
<value>引脚最小面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea_Desc" xml:space="preserve">
<value>识别为引脚焊点的最小像素面积(过滤噪声)</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea" xml:space="preserve">
<value>引脚最大面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea_Desc" xml:space="preserve">
<value>识别为引脚焊点的最大像素面积(排除散热焊盘)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin" xml:space="preserve">
<value>最小长宽比</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc" xml:space="preserve">
<value>引脚最小长宽比(QFN引脚为长条形,长宽比&gt;1</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow" xml:space="preserve">
<value>空洞阈值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow_Desc" xml:space="preserve">
<value>空洞检测灰度下限(空洞为亮区域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh" xml:space="preserve">
<value>空洞阈值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc" xml:space="preserve">
<value>空洞检测灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea" xml:space="preserve">
<value>最小空洞面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea_Desc" xml:space="preserve">
<value>小于此面积的区域视为噪点忽略(像素)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius" xml:space="preserve">
<value>空洞合并半径</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius_Desc" xml:space="preserve">
<value>相邻空洞合并的膨胀半径(0=不合并)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit" xml:space="preserve">
<value>空洞率限值(%)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit_Desc" xml:space="preserve">
<value>单引脚最大允许空洞率(默认50%,参考IPC-7095</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea" xml:space="preserve">
<value>引脚合格面积</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc" xml:space="preserve">
<value>引脚面积低于此值判定为不合格(焊料不足)</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness" xml:space="preserve">
<value>线条粗细</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness_Desc" xml:space="preserve">
<value>轮廓绘制线条粗细</value>
</data>
<data name="QfnAutoDetectionProcessor_Name" xml:space="preserve">
<value>QFN一体检测</value>
</data>
<data name="QfnAutoDetectionProcessor_Description" xml:space="preserve">
<value>模板匹配对齐后,自动完成中心焊盘空洞与引脚空洞检测并汇总判定</value>
</data>
</root>
+98
View File
@@ -1885,4 +1885,102 @@
<value>直方圖 — 暫無資料</value>
<comment>ImageHistogramControl - 無圖像輸入時的提示文字 | Placeholder text when no image data</comment>
</data>
<!-- QfnLeadPadVoidProcessor -->
<data name="QfnLeadPadVoidProcessor_Name" xml:space="preserve">
<value>QFN引腳空洞率檢測</value>
</data>
<data name="QfnLeadPadVoidProcessor_Description" xml:space="preserve">
<value>自動檢測QFN引腳焊點並逐引腳測量空洞率(兩步法:定位引腳 → 檢測空洞)</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode" xml:space="preserve">
<value>ROI模式</value>
</data>
<data name="QfnLeadPadVoidProcessor_RoiMode_Desc" xml:space="preserve">
<value>None: 全圖檢測; Polygon: 多邊形ROI(框選引腳區域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize" xml:space="preserve">
<value>引腳模糊核</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadBlurSize_Desc" xml:space="preserve">
<value>引腳定位時的高斯模糊核大小(奇數)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow" xml:space="preserve">
<value>引腳閾值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdLow_Desc" xml:space="preserve">
<value>引腳分割灰度下限(焊點為暗區域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh" xml:space="preserve">
<value>引腳閾值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadThresholdHigh_Desc" xml:space="preserve">
<value>引腳分割灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel" xml:space="preserve">
<value>形態學核大小</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadMorphKernel_Desc" xml:space="preserve">
<value>閉運算核大小,用於填充引腳內部小孔洞</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea" xml:space="preserve">
<value>引腳最小面積</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinPadArea_Desc" xml:space="preserve">
<value>識別為引腳焊點的最小像素面積(過濾噪聲)</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea" xml:space="preserve">
<value>引腳最大面積</value>
</data>
<data name="QfnLeadPadVoidProcessor_MaxPadArea_Desc" xml:space="preserve">
<value>識別為引腳焊點的最大像素面積(排除散熱焊盤)</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin" xml:space="preserve">
<value>最小長寬比</value>
</data>
<data name="QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc" xml:space="preserve">
<value>引腳最小長寬比(QFN引腳為長條形,長寬比&gt;1</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow" xml:space="preserve">
<value>空洞閾值下限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdLow_Desc" xml:space="preserve">
<value>空洞檢測灰度下限(空洞為亮區域)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh" xml:space="preserve">
<value>空洞閾值上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc" xml:space="preserve">
<value>空洞檢測灰度上限</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea" xml:space="preserve">
<value>最小空洞面積</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinVoidArea_Desc" xml:space="preserve">
<value>小於此面積的區域視為噪點忽略(像素)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius" xml:space="preserve">
<value>空洞合併半徑</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidMergeRadius_Desc" xml:space="preserve">
<value>相鄰空洞合併的膨脹半徑(0=不合併)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit" xml:space="preserve">
<value>空洞率限值(%)</value>
</data>
<data name="QfnLeadPadVoidProcessor_VoidRateLimit_Desc" xml:space="preserve">
<value>單引腳最大允許空洞率(預設50%,參考IPC-7095</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea" xml:space="preserve">
<value>引腳合格面積</value>
</data>
<data name="QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc" xml:space="preserve">
<value>引腳面積低於此值判定為不合格(焊料不足)</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness" xml:space="preserve">
<value>線條粗細</value>
</data>
<data name="QfnLeadPadVoidProcessor_Thickness_Desc" xml:space="preserve">
<value>輪廓繪製線條粗細</value>
</data>
</root>
@@ -18,4 +18,17 @@ public sealed class AlignmentRecipe
/// <summary>变换为整型顶点,供检测算子注入。</summary>
public (int X, int Y)[] TransformRoiToInt(Pose2D measuredPose)
=> RoiAlignment.TransformPolygonToInt(RoiPoints, ReferencePose, measuredPose);
/// <summary>从算子参数字典读取示教多边形并填充 <see cref="RoiPoints"/>。</summary>
public static AlignmentRecipe FromTeachParameters(
Pose2D referencePose,
IReadOnlyDictionary<string, object> parameters)
{
var points = RoiAlignmentApplier.ReadTeachPolygon(parameters);
return new AlignmentRecipe
{
ReferencePose = referencePose,
RoiPoints = points.ToList()
};
}
}
@@ -0,0 +1,146 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// 示教 ROI + 基准/测量位姿 → 运行图多边形;供 <c>RoiAlignment</c> 算子与流水线胶水调用。
/// </summary>
public static class RoiAlignmentApplier
{
public static RoiAlignmentResult Apply(AlignmentRecipe recipe, Pose2D measuredPose)
{
if (recipe == null)
return Fail(default, measuredPose, "Alignment recipe is null.");
return Apply(recipe.ReferencePose, recipe.RoiPoints, measuredPose);
}
public static RoiAlignmentResult Apply(
Pose2D referencePose,
IReadOnlyList<Point2D> teachPoints,
Pose2D measuredPose)
{
if (teachPoints == null || teachPoints.Count < 3)
return Fail(referencePose, measuredPose, "Teach polygon must have at least 3 points.");
var transformed = RoiAlignment.TransformPolygon(teachPoints, referencePose, measuredPose);
var transformedInt = RoiAlignment.TransformPolygonToInt(teachPoints, referencePose, measuredPose);
return new RoiAlignmentResult
{
Success = true,
ReferencePose = referencePose,
MeasuredPose = measuredPose,
TransformedPoints = transformed,
TransformedPointsInt = transformedInt
};
}
public static RoiAlignmentResult ApplyFromTemplateMatchOutput(
AlignmentRecipe recipe,
IReadOnlyDictionary<string, object>? templateMatchOutput,
int matchIndex = 0)
{
if (recipe == null)
return Fail(default, default, "Alignment recipe is null.");
if (!TemplateMatchOutputReader.TryReadMeasuredPose(templateMatchOutput, out var measured, out var error, matchIndex))
return Fail(recipe.ReferencePose, default, error);
return Apply(recipe, measured);
}
public static IReadOnlyList<Point2D> ReadTeachPolygon(IReadOnlyDictionary<string, object> parameters)
{
if (parameters == null)
return Array.Empty<Point2D>();
if (!parameters.TryGetValue(RoiPolygonParameterNames.PolyCount, out var countObj))
return Array.Empty<Point2D>();
int count = Convert.ToInt32(countObj);
if (count < 3)
return Array.Empty<Point2D>();
count = Math.Min(count, RoiPolygonParameterNames.MaxPoints);
var points = new List<Point2D>(count);
for (int i = 0; i < count; i++)
{
if (!parameters.TryGetValue(RoiPolygonParameterNames.PolyX(i), out var xObj)
|| !parameters.TryGetValue(RoiPolygonParameterNames.PolyY(i), out var yObj))
continue;
points.Add(new Point2D(Convert.ToDouble(xObj), Convert.ToDouble(yObj)));
}
return points;
}
/// <summary>
/// 将变换结果写入参数字典(PolyCount、PolyX/PolyY、RoiMode),供下游 VoidMeasurement 等算子使用。
/// </summary>
public static void WriteToParameters(
IDictionary<string, object> parameters,
RoiAlignmentResult result,
bool setRoiModePolygon = true)
{
if (parameters == null)
throw new ArgumentNullException(nameof(parameters));
parameters[RoiAlignmentOutputKeys.Success] = result.Success;
if (!string.IsNullOrEmpty(result.ErrorMessage))
parameters[RoiAlignmentOutputKeys.Message] = result.ErrorMessage!;
if (!result.Success)
return;
int count = result.TransformedPointsInt.Count;
parameters[RoiPolygonParameterNames.PolyCount] = count;
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
if (i < count)
{
parameters[RoiPolygonParameterNames.PolyX(i)] = result.TransformedPointsInt[i].X;
parameters[RoiPolygonParameterNames.PolyY(i)] = result.TransformedPointsInt[i].Y;
}
else
{
parameters[RoiPolygonParameterNames.PolyX(i)] = 0;
parameters[RoiPolygonParameterNames.PolyY(i)] = 0;
}
}
if (setRoiModePolygon)
parameters[RoiPolygonParameterNames.RoiMode] = "Polygon";
}
/// <summary>
/// 将变换结果写入算子 OutputData(与 <see cref="WriteToParameters"/> 键名一致,便于流水线拷贝)。
/// </summary>
public static void WriteToOutputData(IDictionary<string, object> outputData, RoiAlignmentResult result)
{
if (outputData == null)
throw new ArgumentNullException(nameof(outputData));
WriteToParameters(outputData, result);
if (result.Success)
{
outputData["ReferenceCenterX"] = result.ReferencePose.X;
outputData["ReferenceCenterY"] = result.ReferencePose.Y;
outputData["ReferenceAngle"] = result.ReferencePose.AngleDegrees;
outputData["MeasuredCenterX"] = result.MeasuredPose.X;
outputData["MeasuredCenterY"] = result.MeasuredPose.Y;
outputData["MeasuredAngle"] = result.MeasuredPose.AngleDegrees;
outputData["TransformedPointCount"] = result.TransformedPointsInt.Count;
}
}
private static RoiAlignmentResult Fail(Pose2D reference, Pose2D measured, string? message)
=> new()
{
Success = false,
ErrorMessage = message,
ReferencePose = reference,
MeasuredPose = measured
};
}
@@ -0,0 +1,81 @@
# ROI 对齐接入说明(给流水线)
本文用于把 `RotatedTemplateMatching` 的结果接到 ROI 对齐,再把对齐后的 `Poly*` 注入到下游检测算子(如 `VoidMeasurement``QfnLeadPadVoid`)。
## 1. 最小流程
1. 先执行模板匹配(`RotatedTemplateMatching`)。
2. 读取匹配位姿(`CenterX/CenterY/Angle`)。
3.`AlignmentRecipe` 做 ROI 变换。
4. 把变换后的 `PolyCount/PolyX*/PolyY*` 写入下游检测参数。
5. 再执行下游检测算子。
## 2. 推荐调用方式(最少代码)
```csharp
using XP.ImageProcessing.Core.Alignment;
// 1) recipe 来自示教(ReferencePose + RoiPoints
AlignmentRecipe recipe = LoadRecipe();
// 2) templateOutput 为 RotatedTemplateMatching 的 OutputData
if (!RoiAlignmentPipelineBridge.TryAlignFromTemplateMatch(
recipe,
templateOutput,
out var alignResult))
{
// 匹配失败或对齐失败:按业务判定 NG/中断
throw new InvalidOperationException(alignResult.ErrorMessage ?? "ROI alignment failed.");
}
// 3) detectionParams 为下一步检测算子的参数字典(VoidMeasurement / QfnLeadPadVoid 等)
RoiAlignmentApplier.WriteToParameters(detectionParams, alignResult, setRoiModePolygon: true);
// 4) 执行下游检测算子
// ProcessImageWithOutputAsync(..., detectionParams, ...)
```
## 3. 若你走 `RoiAlignment` 算子节点
如果流程里显式放了 `RoiAlignment` 节点:
- 输入参数:`RefCenterX/RefCenterY/RefAngle``MeasuredCenterX/MeasuredCenterY/MeasuredAngle`、示教 `Poly*`
- 输出参数:`PolyCount/PolyX*/PolyY*``RoiAlignmentSuccess``RoiAlignmentMessage`
`RoiAlignment` 的输出字典拷贝到下游检测参数,可用:
```csharp
if (!RoiAlignmentPipelineBridge.TryCopyAlignedRoiToDetectionParameters(
roiAlignmentOutput,
detectionParams,
out var error))
{
throw new InvalidOperationException(error ?? "Copy aligned ROI failed.");
}
```
## 4. 关键键名(常量)
- ROI 对齐输出键:`RoiAlignmentOutputKeys`
- `Success` = `RoiAlignmentSuccess`
- `Message` = `RoiAlignmentMessage`
- `ResultText` = `ResultText`
- `ReferenceCenterX/ReferenceCenterY/ReferenceAngle`
- `MeasuredCenterX/MeasuredCenterY/MeasuredAngle`
- `TransformedPointCount`
- 多边形键:`RoiPolygonParameterNames`
- `PolyCount`
- `PolyX(i)` / `PolyY(i)`
- `RoiMode`(值建议写 `Polygon`
## 5. 失败处理建议
- `TryAlignFromTemplateMatch == false`:模板匹配失败或无效。
- `alignResult.Success == false`:示教点不足(<3)或配方异常。
- `PolyCount < 3`:不要继续下游检测,直接标记本次检测失败。
## 6. 示教数据要求
- `ReferencePose` 必须与示教 ROI 的坐标系一致(同一张示教图)。
- `RoiPoints` 至少 3 点,建议按轮廓顺序保存。
- 建议示教图先自匹配一次后再落 `ReferencePose`,减少中心约定偏差。
@@ -0,0 +1,21 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// RoiAlignment 算子(键 <see cref="ProcessorKey"/>)与 <see cref="RoiAlignmentApplier"/> 的输出/参数字典键名,供流水线拷贝到下游检测算子。
/// </summary>
public static class RoiAlignmentOutputKeys
{
public const string ProcessorKey = "RoiAlignment";
public const string Success = "RoiAlignmentSuccess";
public const string Message = "RoiAlignmentMessage";
public const string ResultText = "ResultText";
public const string ReferenceCenterX = "ReferenceCenterX";
public const string ReferenceCenterY = "ReferenceCenterY";
public const string ReferenceAngle = "ReferenceAngle";
public const string MeasuredCenterX = "MeasuredCenterX";
public const string MeasuredCenterY = "MeasuredCenterY";
public const string MeasuredAngle = "MeasuredAngle";
public const string TransformedPointCount = "TransformedPointCount";
}
@@ -0,0 +1,114 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// 流水线胶水入口:模板匹配 OutputData → ROI 变换 → 写入下游 VoidMeasurement 等算子参数字典。
/// 不依赖 XplorePlane;由 PipelineExecutionService 在步骤间调用。
/// </summary>
public static class RoiAlignmentPipelineBridge
{
/// <summary>
/// 将测量位姿写入 RoiAlignment 算子参数(MeasuredCenterX/Y/Angle)。
/// </summary>
public static void InjectMeasuredPose(IDictionary<string, object> roiAlignmentParameters, Pose2D measuredPose)
{
if (roiAlignmentParameters == null)
throw new ArgumentNullException(nameof(roiAlignmentParameters));
roiAlignmentParameters["MeasuredCenterX"] = measuredPose.X;
roiAlignmentParameters["MeasuredCenterY"] = measuredPose.Y;
roiAlignmentParameters["MeasuredAngle"] = measuredPose.AngleDegrees;
}
/// <summary>
/// 将示教基准位姿写入 RoiAlignment 算子参数(RefCenterX/Y/Angle)。
/// </summary>
public static void InjectReferencePose(IDictionary<string, object> roiAlignmentParameters, Pose2D referencePose)
{
if (roiAlignmentParameters == null)
throw new ArgumentNullException(nameof(roiAlignmentParameters));
roiAlignmentParameters["RefCenterX"] = referencePose.X;
roiAlignmentParameters["RefCenterY"] = referencePose.Y;
roiAlignmentParameters["RefAngle"] = referencePose.AngleDegrees;
}
/// <summary>
/// 从模板匹配一步 OutputData 读取位姿并对齐示教 ROI。
/// </summary>
public static bool TryAlignFromTemplateMatch(
AlignmentRecipe recipe,
IReadOnlyDictionary<string, object>? templateMatchOutput,
out RoiAlignmentResult result,
int matchIndex = 0)
{
result = RoiAlignmentApplier.ApplyFromTemplateMatchOutput(recipe, templateMatchOutput, matchIndex);
return result.Success;
}
/// <summary>
/// 将 RoiAlignment 算子 OutputData 中的 Poly* 拷贝到检测算子参数字典(如 VoidMeasurement)。
/// </summary>
public static bool TryCopyAlignedRoiToDetectionParameters(
IReadOnlyDictionary<string, object>? roiAlignmentOutput,
IDictionary<string, object> detectionParameters,
out string? errorMessage)
{
errorMessage = null;
if (roiAlignmentOutput == null)
{
errorMessage = "ROI alignment output is null.";
return false;
}
if (roiAlignmentOutput.TryGetValue(RoiAlignmentOutputKeys.Success, out var okObj)
&& okObj is bool ok
&& !ok)
{
if (roiAlignmentOutput.TryGetValue(RoiAlignmentOutputKeys.Message, out var msgObj)
&& msgObj is string msg
&& !string.IsNullOrWhiteSpace(msg))
errorMessage = msg;
else
errorMessage = "ROI alignment failed.";
return false;
}
if (!roiAlignmentOutput.TryGetValue(RoiPolygonParameterNames.PolyCount, out var countObj))
{
errorMessage = "ROI alignment output has no PolyCount.";
return false;
}
int count = Convert.ToInt32(countObj);
if (count < 3)
{
errorMessage = $"Invalid aligned PolyCount={count}.";
return false;
}
RoiAlignmentApplier.WriteToParameters(detectionParameters, new RoiAlignmentResult
{
Success = true,
TransformedPointsInt = ReadIntPointsFromOutput(roiAlignmentOutput, count)
});
return true;
}
private static IReadOnlyList<(int X, int Y)> ReadIntPointsFromOutput(
IReadOnlyDictionary<string, object> output,
int count)
{
var list = new List<(int X, int Y)>(count);
for (int i = 0; i < count; i++)
{
int x = output.TryGetValue(RoiPolygonParameterNames.PolyX(i), out var xo)
? Convert.ToInt32(xo) : 0;
int y = output.TryGetValue(RoiPolygonParameterNames.PolyY(i), out var yo)
? Convert.ToInt32(yo) : 0;
list.Add((x, y));
}
return list;
}
}
@@ -0,0 +1,19 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// ROI 对齐变换结果,供流水线写入下游检测算子参数。
/// </summary>
public sealed class RoiAlignmentResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public Pose2D ReferencePose { get; init; }
public Pose2D MeasuredPose { get; init; }
/// <summary>运行图坐标下的多边形顶点(至少 3 点)。</summary>
public IReadOnlyList<Point2D> TransformedPoints { get; init; } = Array.Empty<Point2D>();
/// <summary>四舍五入后的整型顶点,可直接写入 PolyX/PolyY。</summary>
public IReadOnlyList<(int X, int Y)> TransformedPointsInt { get; init; } = Array.Empty<(int X, int Y)>();
}
@@ -0,0 +1,15 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// 与 VoidMeasurement / BgaVoidRate / QfnLeadPadVoid 等算子一致的多边形 ROI 参数名。
/// </summary>
public static class RoiPolygonParameterNames
{
public const int MaxPoints = 32;
public const string RoiMode = "RoiMode";
public const string PolyCount = "PolyCount";
public static string PolyX(int index) => $"PolyX{index}";
public static string PolyY(int index) => $"PolyY{index}";
}
@@ -0,0 +1,82 @@
namespace XP.ImageProcessing.Core.Alignment;
/// <summary>
/// 从旋转模板匹配算子(键 <see cref="ProcessorKey"/>)的 OutputData 读取测量位姿。
/// </summary>
public static class TemplateMatchOutputReader
{
public const string ProcessorKey = "RotatedTemplateMatching";
public static bool TryReadMeasuredPose(
IReadOnlyDictionary<string, object>? outputData,
out Pose2D measuredPose,
out string? errorMessage,
int matchIndex = 0)
{
measuredPose = default;
errorMessage = null;
if (outputData == null || outputData.Count == 0)
{
errorMessage = "Template match output is empty.";
return false;
}
if (outputData.TryGetValue("Matched", out var matchedObj)
&& matchedObj is bool matched
&& !matched)
{
if (outputData.TryGetValue("Message", out var msgObj) && msgObj is string msg && !string.IsNullOrWhiteSpace(msg))
errorMessage = msg;
else
errorMessage = "Template match failed (Matched=false).";
return false;
}
if (outputData.TryGetValue("MatchCount", out var countObj))
{
int count = Convert.ToInt32(countObj);
if (count <= 0)
{
errorMessage = "Template match count is zero.";
return false;
}
if (matchIndex < 0 || matchIndex >= count)
{
errorMessage = $"Match index {matchIndex} is out of range (MatchCount={count}).";
return false;
}
}
string prefix = matchIndex == 0 ? string.Empty : $"[{matchIndex}]";
if (!TryReadDouble(outputData, "CenterX" + prefix, out double cx)
|| !TryReadDouble(outputData, "CenterY" + prefix, out double cy)
|| !TryReadDouble(outputData, "Angle" + prefix, out double angle))
{
errorMessage = "Template match output is missing CenterX/CenterY/Angle.";
return false;
}
measuredPose = new Pose2D(cx, cy, angle);
return true;
}
private static bool TryReadDouble(IReadOnlyDictionary<string, object> data, string key, out double value)
{
value = 0;
if (!data.TryGetValue(key, out var obj) || obj == null)
return false;
try
{
value = Convert.ToDouble(obj);
return true;
}
catch
{
return false;
}
}
}
@@ -73,27 +73,30 @@ public class ThresholdProcessor : ImageProcessorBase
if (useOtsu)
{
// 使用Otsu算法
CvInvoke.Threshold(inputImage, result, minThreshold, 255, ThresholdType.Otsu);
_logger.Debug("Process: UseOtsu = true");
}
else
{
// 双阈值分割:介于MinThreshold和MaxThreshold之间的为前景(255),其他为背景(0)
byte[,,] inputData = inputImage.Data;
byte[,,] outputData = result.Data;
int height = inputImage.Height;
int width = inputImage.Width;
unsafe
{
byte* srcPtr = (byte*)inputImage.Mat.DataPointer;
byte* dstPtr = (byte*)result.Mat.DataPointer;
int srcStep = inputImage.Mat.Step;
int dstStep = result.Mat.Step;
for (int y = 0; y < height; y++)
{
byte* srcRow = srcPtr + y * srcStep;
byte* dstRow = dstPtr + y * dstStep;
for (int x = 0; x < width; x++)
{
byte pixelValue = inputData[y, x, 0];
outputData[y, x, 0] = (pixelValue >= minThreshold && pixelValue <= maxThreshold)
? (byte)255
: (byte)0;
byte val = srcRow[x];
dstRow[x] = (val >= minThreshold && val <= maxThreshold) ? (byte)255 : (byte)0;
}
}
}
@@ -135,38 +135,94 @@ public class ContrastProcessor : ImageProcessorBase
int tileSize = 8;
int width = inputImage.Width;
int height = inputImage.Height;
byte[,,] srcData = inputImage.Data;
// 计算分块数
int tilesX = (width + tileSize - 1) / tileSize;
int tilesY = (height + tileSize - 1) / tileSize;
int actualTileW = (width + tilesX - 1) / tilesX;
int actualTileH = (height + tilesY - 1) / tilesY;
var result = new Image<Gray, byte>(width, height);
// 为每个 tile 计算带 clip limit 的均衡化映射表
var luts = new byte[tilesY, tilesX, 256];
for (int ty = 0; ty < tilesY; ty++)
{
for (int tx = 0; tx < tilesX; tx++)
{
int x = tx * tileSize;
int y = ty * tileSize;
int w = Math.Min(tileSize, width - x);
int h = Math.Min(tileSize, height - y);
int x0 = tx * actualTileW;
int y0 = ty * actualTileH;
int x1 = Math.Min(x0 + actualTileW, width);
int y1 = Math.Min(y0 + actualTileH, height);
int tilePixels = (x1 - x0) * (y1 - y0);
var roi = new System.Drawing.Rectangle(x, y, w, h);
inputImage.ROI = roi;
var tile = inputImage.Copy();
inputImage.ROI = System.Drawing.Rectangle.Empty;
// 构建直方图
var hist = new int[256];
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
hist[srcData[y, x, 0]]++;
var equalizedTile = new Image<Gray, byte>(tile.Size);
CvInvoke.EqualizeHist(tile, equalizedTile);
result.ROI = roi;
equalizedTile.CopyTo(result);
result.ROI = System.Drawing.Rectangle.Empty;
tile.Dispose();
equalizedTile.Dispose();
// Clip limit 裁剪并重新分配
int clipThreshold = (int)(clipLimit * tilePixels / 256);
if (clipThreshold > 0)
{
int excess = 0;
for (int i = 0; i < 256; i++)
{
if (hist[i] > clipThreshold)
{
excess += hist[i] - clipThreshold;
hist[i] = clipThreshold;
}
}
_logger.Debug("ApplyCLAHE");
int avgInc = excess / 256;
int remainder = excess - avgInc * 256;
for (int i = 0; i < 256; i++)
hist[i] += avgInc + (i < remainder ? 1 : 0);
}
// 构建 CDF 映射表
int sum = 0;
for (int i = 0; i < 256; i++)
{
sum += hist[i];
luts[ty, tx, i] = (byte)Math.Clamp(sum * 255 / tilePixels, 0, 255);
}
}
}
// 双线性插值生成结果
var result = new Image<Gray, byte>(width, height);
byte[,,] dstData = result.Data;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
// 计算当前像素所在 tile 的中心坐标
double fx = (double)x / actualTileW - 0.5;
double fy = (double)y / actualTileH - 0.5;
int tx0 = Math.Max(0, (int)fx);
int ty0 = Math.Max(0, (int)fy);
int tx1 = Math.Min(tx0 + 1, tilesX - 1);
int ty1 = Math.Min(ty0 + 1, tilesY - 1);
double ax = fx - tx0;
double ay = fy - ty0;
ax = Math.Clamp(ax, 0, 1);
ay = Math.Clamp(ay, 0, 1);
byte val = srcData[y, x, 0];
double v00 = luts[ty0, tx0, val];
double v10 = luts[ty0, tx1, val];
double v01 = luts[ty1, tx0, val];
double v11 = luts[ty1, tx1, val];
double interpolated = v00 * (1 - ax) * (1 - ay) + v10 * ax * (1 - ay)
+ v01 * (1 - ax) * ay + v11 * ax * ay;
dstData[y, x, 0] = (byte)Math.Clamp((int)(interpolated + 0.5), 0, 255);
}
}
_logger.Debug("ApplyCLAHE: ClipLimit={ClipLimit}, TileSize={TileSize}", clipLimit, tileSize);
return result;
}
}
@@ -0,0 +1,130 @@
// ============================================================================
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
// 文件名: RoiAlignmentProcessor.cs
// 描述: 示教多边形 ROI 刚体对齐(平移+旋转)
//
// 流水线约定:
// 输入: 示教 Poly* + ReferencePose(Ref*) + 上一步模板匹配的 MeasuredPose(Measured*)
// 输出: OutputData 中含变换后的 PolyCount/PolyX/PolyY/RoiMode,可拷贝到 VoidMeasurement 等算子
// 或: 流水线直接调用 XP.ImageProcessing.Core.Alignment.RoiAlignmentApplier
// ============================================================================
using Emgu.CV;
using Emgu.CV.Structure;
using XP.ImageProcessing.Core;
using XP.ImageProcessing.Core.Alignment;
using Serilog;
namespace XP.ImageProcessing.Processors;
/// <summary>
/// 将示教图上的多边形 ROI 变换到当前图(不修改图像,仅输出对齐后的 ROI 参数)。
/// </summary>
public class RoiAlignmentProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<RoiAlignmentProcessor>();
public RoiAlignmentProcessor()
{
Name = LocalizationHelper.GetString("RoiAlignmentProcessor_Name");
Description = LocalizationHelper.GetString("RoiAlignmentProcessor_Description");
}
protected override void InitializeParameters()
{
Parameters.Add("RefCenterX", new ProcessorParameter(
"RefCenterX",
LocalizationHelper.GetString("RoiAlignmentProcessor_RefCenterX"),
typeof(double), 0.0, null, null,
LocalizationHelper.GetString("RoiAlignmentProcessor_RefCenterX_Desc")));
Parameters.Add("RefCenterY", new ProcessorParameter(
"RefCenterY",
LocalizationHelper.GetString("RoiAlignmentProcessor_RefCenterY"),
typeof(double), 0.0, null, null,
LocalizationHelper.GetString("RoiAlignmentProcessor_RefCenterY_Desc")));
Parameters.Add("RefAngle", new ProcessorParameter(
"RefAngle",
LocalizationHelper.GetString("RoiAlignmentProcessor_RefAngle"),
typeof(double), 0.0, -180.0, 180.0,
LocalizationHelper.GetString("RoiAlignmentProcessor_RefAngle_Desc")));
Parameters.Add("MeasuredCenterX", new ProcessorParameter(
"MeasuredCenterX",
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredCenterX"),
typeof(double), 0.0, null, null,
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredCenterX_Desc")));
Parameters.Add("MeasuredCenterY", new ProcessorParameter(
"MeasuredCenterY",
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredCenterY"),
typeof(double), 0.0, null, null,
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredCenterY_Desc")));
Parameters.Add("MeasuredAngle", new ProcessorParameter(
"MeasuredAngle",
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredAngle"),
typeof(double), 0.0, -180.0, 180.0,
LocalizationHelper.GetString("RoiAlignmentProcessor_MeasuredAngle_Desc")));
Parameters.Add(RoiPolygonParameterNames.PolyCount, new ProcessorParameter(
RoiPolygonParameterNames.PolyCount,
RoiPolygonParameterNames.PolyCount,
typeof(int), 0, null, null,
"") { IsVisible = false });
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
Parameters.Add(RoiPolygonParameterNames.PolyX(i), new ProcessorParameter(
RoiPolygonParameterNames.PolyX(i),
RoiPolygonParameterNames.PolyX(i),
typeof(int), 0, null, null,
"") { IsVisible = false });
Parameters.Add(RoiPolygonParameterNames.PolyY(i), new ProcessorParameter(
RoiPolygonParameterNames.PolyY(i),
RoiPolygonParameterNames.PolyY(i),
typeof(int), 0, null, null,
"") { IsVisible = false });
}
}
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
{
OutputData.Clear();
var reference = new Pose2D(
GetParameter<double>("RefCenterX"),
GetParameter<double>("RefCenterY"),
GetParameter<double>("RefAngle"));
var measured = new Pose2D(
GetParameter<double>("MeasuredCenterX"),
GetParameter<double>("MeasuredCenterY"),
GetParameter<double>("MeasuredAngle"));
var paramDict = Parameters.ToDictionary(p => p.Key, p => p.Value.Value);
var teachPoints = RoiAlignmentApplier.ReadTeachPolygon(paramDict);
var result = RoiAlignmentApplier.Apply(reference, teachPoints, measured);
RoiAlignmentApplier.WriteToOutputData(OutputData, result);
OutputData[RoiAlignmentOutputKeys.Success] = result.Success;
if (!string.IsNullOrEmpty(result.ErrorMessage))
OutputData[RoiAlignmentOutputKeys.Message] = result.ErrorMessage!;
OutputData["ResultText"] = OutputData[RoiAlignmentOutputKeys.ResultText] = result.Success
? $"ROI aligned: {result.TransformedPointsInt.Count} pts"
: $"ROI align failed: {result.ErrorMessage}";
if (result.Success)
_logger.Debug("RoiAlignment: {Count} points, ref=({Rx:F1},{Ry:F1},{Ra:F1}) meas=({Mx:F1},{My:F1},{Ma:F1})",
result.TransformedPointsInt.Count,
reference.X, reference.Y, reference.AngleDegrees,
measured.X, measured.Y, measured.AngleDegrees);
else
_logger.Warning("RoiAlignment failed: {Msg}", result.ErrorMessage);
return inputImage.Clone();
}
}
@@ -24,4 +24,19 @@ public static class TemplateMatchAlignmentExtensions
result.LbX,
result.LbY,
tolerancePixels);
/// <summary>从 RotatedTemplateMatching 的 OutputData 读取测量位姿。</summary>
public static bool TryReadMeasuredPose(
IReadOnlyDictionary<string, object>? outputData,
out Pose2D measuredPose,
out string? errorMessage,
int matchIndex = 0)
=> TemplateMatchOutputReader.TryReadMeasuredPose(outputData, out measuredPose, out errorMessage, matchIndex);
/// <summary>用匹配结果 + 示教配方一步得到对齐后的 ROI。</summary>
public static RoiAlignmentResult AlignRecipe(
AlignmentRecipe recipe,
IReadOnlyDictionary<string, object>? templateMatchOutput,
int matchIndex = 0)
=> RoiAlignmentApplier.ApplyFromTemplateMatchOutput(recipe, templateMatchOutput, matchIndex);
}
@@ -0,0 +1,345 @@
// ============================================================================
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
// 文件名: QfnAutoDetectionProcessor.cs
// 描述: QFN 一体检测算子(模板匹配对齐 + 中心空洞 + 引脚空洞)
// ============================================================================
using Emgu.CV;
using Emgu.CV.Structure;
using Serilog;
using XP.ImageProcessing.Core;
using XP.ImageProcessing.Core.Alignment;
namespace XP.ImageProcessing.Processors;
/// <summary>
/// 一体化 QFN 检测:先模板匹配获取位姿,再将中心 ROI 对齐后做中心空洞检测,最后做引脚空洞检测并汇总判定。
/// </summary>
public sealed class QfnAutoDetectionProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<QfnAutoDetectionProcessor>();
public QfnAutoDetectionProcessor()
{
Name = LocalizationHelper.GetString("QfnAutoDetectionProcessor_Name");
Description = LocalizationHelper.GetString("QfnAutoDetectionProcessor_Description");
}
protected override void InitializeParameters()
{
// 模板匹配参数
Parameters.Add("TemplatePath", new ProcessorParameter("TemplatePath", "TemplatePath", typeof(string), string.Empty, null, null, ""));
Parameters.Add("ModelPath", new ProcessorParameter("ModelPath", "ModelPath", typeof(string), string.Empty, null, null, ""));
Parameters.Add("MatchThreshold", new ProcessorParameter("MatchThreshold", "MatchThreshold", typeof(double), 0.75, 0.0, 1.0, ""));
Parameters.Add("ToleranceAngle", new ProcessorParameter("ToleranceAngle", "ToleranceAngle", typeof(double), 0.0, 0.0, 180.0, ""));
Parameters.Add("MaxOverlap", new ProcessorParameter("MaxOverlap", "MaxOverlap", typeof(double), 0.3, 0.0, 1.0, ""));
Parameters.Add("MinReduceArea", new ProcessorParameter("MinReduceArea", "MinReduceArea", typeof(int), 256, 64, 4096, ""));
Parameters.Add("UseSIMD", new ProcessorParameter("UseSIMD", "UseSIMD", typeof(bool), true, null, null, ""));
Parameters.Add("UseSubPixel", new ProcessorParameter("UseSubPixel", "UseSubPixel", typeof(bool), false, null, null, ""));
Parameters.Add("MatchIndex", new ProcessorParameter("MatchIndex", "MatchIndex", typeof(int), 0, 0, 99, ""));
// 对齐基准位姿(示教)
Parameters.Add("RefCenterX", new ProcessorParameter("RefCenterX", "RefCenterX", typeof(double), 0.0, null, null, ""));
Parameters.Add("RefCenterY", new ProcessorParameter("RefCenterY", "RefCenterY", typeof(double), 0.0, null, null, ""));
Parameters.Add("RefAngle", new ProcessorParameter("RefAngle", "RefAngle", typeof(double), 0.0, -180.0, 180.0, ""));
// 中心焊盘示教多边形(会随位姿对齐)
AddHiddenPolygonParams("Center");
// 中心空洞参数(映射到 VoidMeasurementProcessor
Parameters.Add("CenterMinThreshold", new ProcessorParameter("CenterMinThreshold", "CenterMinThreshold", typeof(int), 128, 0, 255, ""));
Parameters.Add("CenterMaxThreshold", new ProcessorParameter("CenterMaxThreshold", "CenterMaxThreshold", typeof(int), 255, 0, 255, ""));
Parameters.Add("CenterMinVoidArea", new ProcessorParameter("CenterMinVoidArea", "CenterMinVoidArea", typeof(int), 10, 1, 100000, ""));
Parameters.Add("CenterMergeRadius", new ProcessorParameter("CenterMergeRadius", "CenterMergeRadius", typeof(int), 3, 0, 30, ""));
Parameters.Add("CenterBlurSize", new ProcessorParameter("CenterBlurSize", "CenterBlurSize", typeof(int), 3, 1, 31, ""));
Parameters.Add("CenterVoidLimit", new ProcessorParameter("CenterVoidLimit", "CenterVoidLimit", typeof(double), 25.0, 0.0, 100.0, ""));
// 引脚检测参数(映射到 QfnLeadPadVoidProcessor
Parameters.Add("LeadRoiMode", new ProcessorParameter("LeadRoiMode", "LeadRoiMode", typeof(string), "None", null, null, "", new[] { "None", "Polygon" }));
Parameters.Add("AlignLeadRoiWithTemplate", new ProcessorParameter("AlignLeadRoiWithTemplate", "AlignLeadRoiWithTemplate", typeof(bool), false, null, null, ""));
AddHiddenPolygonParams("Lead");
Parameters.Add("LeadPadBlurSize", new ProcessorParameter("LeadPadBlurSize", "LeadPadBlurSize", typeof(int), 5, 1, 31, ""));
Parameters.Add("LeadPadThresholdLow", new ProcessorParameter("LeadPadThresholdLow", "LeadPadThresholdLow", typeof(int), 0, 0, 255, ""));
Parameters.Add("LeadPadThresholdHigh", new ProcessorParameter("LeadPadThresholdHigh", "LeadPadThresholdHigh", typeof(int), 120, 0, 255, ""));
Parameters.Add("LeadPadMorphKernel", new ProcessorParameter("LeadPadMorphKernel", "LeadPadMorphKernel", typeof(int), 5, 1, 31, ""));
Parameters.Add("LeadMinPadArea", new ProcessorParameter("LeadMinPadArea", "LeadMinPadArea", typeof(int), 200, 10, 1000000, ""));
Parameters.Add("LeadMaxPadArea", new ProcessorParameter("LeadMaxPadArea", "LeadMaxPadArea", typeof(int), 100000, 100, 10000000, ""));
Parameters.Add("LeadPadAspectRatioMin", new ProcessorParameter("LeadPadAspectRatioMin", "LeadPadAspectRatioMin", typeof(double), 1.2, 0.1, 20.0, ""));
Parameters.Add("LeadVoidThresholdLow", new ProcessorParameter("LeadVoidThresholdLow", "LeadVoidThresholdLow", typeof(int), 128, 0, 255, ""));
Parameters.Add("LeadVoidThresholdHigh", new ProcessorParameter("LeadVoidThresholdHigh", "LeadVoidThresholdHigh", typeof(int), 255, 0, 255, ""));
Parameters.Add("LeadMinVoidArea", new ProcessorParameter("LeadMinVoidArea", "LeadMinVoidArea", typeof(int), 5, 1, 10000, ""));
Parameters.Add("LeadVoidMergeRadius", new ProcessorParameter("LeadVoidMergeRadius", "LeadVoidMergeRadius", typeof(int), 2, 0, 20, ""));
Parameters.Add("LeadVoidRateLimit", new ProcessorParameter("LeadVoidRateLimit", "LeadVoidRateLimit", typeof(double), 50.0, 0.0, 100.0, ""));
Parameters.Add("LeadMinQualifiedPadArea", new ProcessorParameter("LeadMinQualifiedPadArea", "LeadMinQualifiedPadArea", typeof(int), 1000, 0, 1000000, ""));
Parameters.Add("LeadThickness", new ProcessorParameter("LeadThickness", "LeadThickness", typeof(int), 2, 1, 10, ""));
}
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
{
OutputData.Clear();
var output = inputImage.Clone();
try
{
// 1) 模板匹配
var templateResult = RunTemplateMatching(inputImage, out var templateOutput);
OutputData["TemplateOutput"] = templateOutput;
if (!templateResult.Success)
{
FillFailResult("FAIL_MATCH", templateResult.ErrorMessage ?? "Template matching failed.");
return output;
}
// 2) 中心 ROI 对齐
var referencePose = new Pose2D(
GetParameter<double>("RefCenterX"),
GetParameter<double>("RefCenterY"),
GetParameter<double>("RefAngle"));
var centerTeach = ReadPrefixedPolygon("Center");
var centerAlign = RoiAlignmentApplier.Apply(referencePose, centerTeach, templateResult.MeasuredPose);
OutputData["CenterAlignment"] = centerAlign;
if (!centerAlign.Success)
{
FillFailResult("FAIL_ALIGN_CENTER", centerAlign.ErrorMessage ?? "Center ROI alignment failed.");
return output;
}
// 3) 中心空洞
var centerDetection = RunCenterVoid(inputImage, centerAlign);
OutputData["CenterOutput"] = centerDetection.Output;
if (!centerDetection.Success)
{
FillFailResult("FAIL_CENTER", centerDetection.ErrorMessage ?? "Center void detection failed.");
return output;
}
// 4) 引脚空洞
var leadDetection = RunLeadVoid(inputImage, templateResult.MeasuredPose);
OutputData["LeadOutput"] = leadDetection.Output;
if (!leadDetection.Success)
{
FillFailResult("FAIL_LEAD", leadDetection.ErrorMessage ?? "Lead void detection failed.");
return output;
}
// 5) 汇总
string centerClass = ReadString(centerDetection.Output, "Classification", "N/A");
string leadClass = ReadString(leadDetection.Output, "Classification", "N/A");
string overall = (centerClass == "PASS" && leadClass == "PASS") ? "PASS" : "FAIL";
OutputData["QfnAutoDetectionResult"] = true;
OutputData["TemplateMatched"] = true;
OutputData["MeasuredPose"] = templateResult.MeasuredPose;
OutputData["CenterVoidRate"] = ReadDouble(centerDetection.Output, "VoidRate");
OutputData["CenterClassification"] = centerClass;
OutputData["LeadVoidRate"] = ReadDouble(leadDetection.Output, "VoidRate");
OutputData["LeadClassification"] = leadClass;
OutputData["LeadCount"] = ReadInt(leadDetection.Output, "LeadCount");
OutputData["Classification"] = overall;
OutputData["ResultText"] =
$"QFN Auto: {overall} | Center={ReadDouble(centerDetection.Output, "VoidRate"):F1}%({centerClass}) | Lead={ReadDouble(leadDetection.Output, "VoidRate"):F1}%({leadClass})";
_logger.Information("QfnAutoDetection: {Class}, Center={CenterClass}, Lead={LeadClass}",
overall, centerClass, leadClass);
return output;
}
catch (Exception ex)
{
_logger.Error(ex, "QfnAutoDetection failed");
FillFailResult("FAIL_EXCEPTION", ex.Message);
return output;
}
}
private (bool Success, Pose2D MeasuredPose, string? ErrorMessage) RunTemplateMatching(
Image<Gray, byte> inputImage,
out Dictionary<string, object> templateOutput)
{
var proc = new RotatedTemplateMatchingProcessor();
proc.SetParameter("TemplatePath", GetParameter<string>("TemplatePath"));
proc.SetParameter("ModelPath", GetParameter<string>("ModelPath"));
proc.SetParameter("MatchThreshold", GetParameter<double>("MatchThreshold"));
proc.SetParameter("MaxMatchCount", 100);
proc.SetParameter("ToleranceAngle", GetParameter<double>("ToleranceAngle"));
proc.SetParameter("MaxOverlap", GetParameter<double>("MaxOverlap"));
proc.SetParameter("MinReduceArea", GetParameter<int>("MinReduceArea"));
proc.SetParameter("UseSIMD", GetParameter<bool>("UseSIMD"));
proc.SetParameter("UseSubPixel", GetParameter<bool>("UseSubPixel"));
proc.SetParameter("DrawResults", false);
proc.SetParameter("DrawThickness", 1);
var processed = proc.Process(inputImage);
processed.Dispose();
templateOutput = new Dictionary<string, object>(proc.OutputData);
int matchIndex = GetParameter<int>("MatchIndex");
if (!TemplateMatchOutputReader.TryReadMeasuredPose(templateOutput, out var measuredPose, out var error, matchIndex))
return (false, default, error);
return (true, measuredPose, null);
}
private (bool Success, Dictionary<string, object> Output, string? ErrorMessage) RunCenterVoid(
Image<Gray, byte> inputImage,
RoiAlignmentResult centerAlign)
{
var proc = new VoidMeasurementProcessor();
proc.SetParameter("MinThreshold", GetParameter<int>("CenterMinThreshold"));
proc.SetParameter("MaxThreshold", GetParameter<int>("CenterMaxThreshold"));
proc.SetParameter("MinVoidArea", GetParameter<int>("CenterMinVoidArea"));
proc.SetParameter("MergeRadius", GetParameter<int>("CenterMergeRadius"));
proc.SetParameter("BlurSize", GetParameter<int>("CenterBlurSize"));
proc.SetParameter("VoidLimit", GetParameter<double>("CenterVoidLimit"));
proc.SetParameter("PolyCount", centerAlign.TransformedPointsInt.Count);
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
int x = i < centerAlign.TransformedPointsInt.Count ? centerAlign.TransformedPointsInt[i].X : 0;
int y = i < centerAlign.TransformedPointsInt.Count ? centerAlign.TransformedPointsInt[i].Y : 0;
proc.SetParameter($"PolyX{i}", x);
proc.SetParameter($"PolyY{i}", y);
}
var processed = proc.Process(inputImage);
processed.Dispose();
var output = new Dictionary<string, object>(proc.OutputData);
bool ok = output.ContainsKey("VoidMeasurementResult") && output["VoidMeasurementResult"] is bool b && b;
return (ok, output, ok ? null : "Center void processor did not return success.");
}
private (bool Success, Dictionary<string, object> Output, string? ErrorMessage) RunLeadVoid(
Image<Gray, byte> inputImage,
Pose2D measuredPose)
{
var proc = new QfnLeadPadVoidProcessor();
proc.SetParameter("PadBlurSize", GetParameter<int>("LeadPadBlurSize"));
proc.SetParameter("PadThresholdLow", GetParameter<int>("LeadPadThresholdLow"));
proc.SetParameter("PadThresholdHigh", GetParameter<int>("LeadPadThresholdHigh"));
proc.SetParameter("PadMorphKernel", GetParameter<int>("LeadPadMorphKernel"));
proc.SetParameter("MinPadArea", GetParameter<int>("LeadMinPadArea"));
proc.SetParameter("MaxPadArea", GetParameter<int>("LeadMaxPadArea"));
proc.SetParameter("PadAspectRatioMin", GetParameter<double>("LeadPadAspectRatioMin"));
proc.SetParameter("VoidThresholdLow", GetParameter<int>("LeadVoidThresholdLow"));
proc.SetParameter("VoidThresholdHigh", GetParameter<int>("LeadVoidThresholdHigh"));
proc.SetParameter("MinVoidArea", GetParameter<int>("LeadMinVoidArea"));
proc.SetParameter("VoidMergeRadius", GetParameter<int>("LeadVoidMergeRadius"));
proc.SetParameter("VoidRateLimit", GetParameter<double>("LeadVoidRateLimit"));
proc.SetParameter("MinQualifiedPadArea", GetParameter<int>("LeadMinQualifiedPadArea"));
proc.SetParameter("Thickness", GetParameter<int>("LeadThickness"));
string leadRoiMode = GetParameter<string>("LeadRoiMode");
bool alignLeadRoi = GetParameter<bool>("AlignLeadRoiWithTemplate");
if (leadRoiMode == "Polygon")
{
IReadOnlyList<(int X, int Y)> points;
if (alignLeadRoi)
{
var referencePose = new Pose2D(
GetParameter<double>("RefCenterX"),
GetParameter<double>("RefCenterY"),
GetParameter<double>("RefAngle"));
var leadTeach = ReadPrefixedPolygon("Lead");
var leadAlign = RoiAlignmentApplier.Apply(referencePose, leadTeach, measuredPose);
if (!leadAlign.Success)
return (false, new Dictionary<string, object>(), leadAlign.ErrorMessage);
points = leadAlign.TransformedPointsInt;
}
else
{
var teach = ReadPrefixedPolygon("Lead");
var list = new List<(int X, int Y)>(teach.Count);
foreach (var p in teach)
list.Add(((int)Math.Round(p.X), (int)Math.Round(p.Y)));
points = list;
}
proc.SetParameter("RoiMode", "Polygon");
proc.SetParameter("PolyCount", points.Count);
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
int x = i < points.Count ? points[i].X : 0;
int y = i < points.Count ? points[i].Y : 0;
proc.SetParameter($"PolyX{i}", x);
proc.SetParameter($"PolyY{i}", y);
}
}
else
{
proc.SetParameter("RoiMode", "None");
proc.SetParameter("PolyCount", 0);
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
proc.SetParameter($"PolyX{i}", 0);
proc.SetParameter($"PolyY{i}", 0);
}
}
var processed = proc.Process(inputImage);
processed.Dispose();
var output = new Dictionary<string, object>(proc.OutputData);
bool ok = output.ContainsKey("QfnLeadResult") && output["QfnLeadResult"] is bool b && b;
return (ok, output, ok ? null : "Qfn lead processor did not return success.");
}
private void FillFailResult(string failCode, string message)
{
OutputData["QfnAutoDetectionResult"] = false;
OutputData["Classification"] = "FAIL";
OutputData["FailCode"] = failCode;
OutputData["ResultText"] = $"QFN Auto: {failCode} | {message}";
}
private void AddHiddenPolygonParams(string prefix)
{
Parameters.Add($"{prefix}PolyCount", new ProcessorParameter($"{prefix}PolyCount", $"{prefix}PolyCount", typeof(int), 0, null, null, "")
{
IsVisible = false
});
for (int i = 0; i < RoiPolygonParameterNames.MaxPoints; i++)
{
Parameters.Add($"{prefix}PolyX{i}", new ProcessorParameter($"{prefix}PolyX{i}", $"{prefix}PolyX{i}", typeof(int), 0, null, null, "")
{
IsVisible = false
});
Parameters.Add($"{prefix}PolyY{i}", new ProcessorParameter($"{prefix}PolyY{i}", $"{prefix}PolyY{i}", typeof(int), 0, null, null, "")
{
IsVisible = false
});
}
}
private IReadOnlyList<Point2D> ReadPrefixedPolygon(string prefix)
{
int count = GetParameter<int>($"{prefix}PolyCount");
if (count < 3) return Array.Empty<Point2D>();
count = Math.Min(count, RoiPolygonParameterNames.MaxPoints);
var points = new List<Point2D>(count);
for (int i = 0; i < count; i++)
{
points.Add(new Point2D(
GetParameter<int>($"{prefix}PolyX{i}"),
GetParameter<int>($"{prefix}PolyY{i}")));
}
return points;
}
private static string ReadString(IReadOnlyDictionary<string, object> data, string key, string defaultValue)
=> data.TryGetValue(key, out var v) && v is string s ? s : defaultValue;
private static int ReadInt(IReadOnlyDictionary<string, object> data, string key)
=> data.TryGetValue(key, out var v) ? Convert.ToInt32(v) : 0;
private static double ReadDouble(IReadOnlyDictionary<string, object> data, string key)
=> data.TryGetValue(key, out var v) ? Convert.ToDouble(v) : 0.0;
}
@@ -0,0 +1,528 @@
// ============================================================================
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
// 文件名: QfnLeadPadVoidProcessor.cs
// 描述: QFN 单引脚空洞率检测算子
//
// 处理流程:
// 第一步 — 引脚定位: 高斯模糊 → 双阈值分割 → 形态学闭运算 → 轮廓检测
// → 面积/长宽比过滤 → 排除散热焊盘 → 引脚排序
// 第二步 — 空洞检测: 逐引脚掩码 → 双阈值分割 → 轮廓检测 → 面积过滤 → 空洞率计算
//
// 支持多边形ROI限定检测区域(框选引脚所在边),支持IPC-7095标准PASS/FAIL判定
// 正片模式:焊点=暗区域,空洞=亮区域
//
// 作者: 李伟 wei.lw.li@hexagon.com
// ============================================================================
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
using XP.ImageProcessing.Core;
using Serilog;
using System.Drawing;
namespace XP.ImageProcessing.Processors;
public class QfnLeadPadVoidProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<QfnLeadPadVoidProcessor>();
public QfnLeadPadVoidProcessor()
{
Name = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Name");
Description = LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Description");
}
protected override void InitializeParameters()
{
// ── ROI限定区域 ──
Parameters.Add("RoiMode", new ProcessorParameter(
"RoiMode",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode"),
typeof(string), "None", null, null,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_RoiMode_Desc"),
new string[] { "None", "Polygon" }));
// 多边形ROI点数和坐标(由UI注入,不可见,最多支持32个点)
Parameters.Add("PolyCount", new ProcessorParameter("PolyCount", "PolyCount", typeof(int), 0, null, null, "") { IsVisible = false });
for (int i = 0; i < 32; i++)
{
Parameters.Add($"PolyX{i}", new ProcessorParameter($"PolyX{i}", $"PolyX{i}", typeof(int), 0, null, null, "") { IsVisible = false });
Parameters.Add($"PolyY{i}", new ProcessorParameter($"PolyY{i}", $"PolyY{i}", typeof(int), 0, null, null, "") { IsVisible = false });
}
// ── 第一步:引脚定位参数 ──
Parameters.Add("PadBlurSize", new ProcessorParameter(
"PadBlurSize",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize"),
typeof(int), 5, 1, 31,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadBlurSize_Desc")));
Parameters.Add("PadThresholdLow", new ProcessorParameter(
"PadThresholdLow",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow"),
typeof(int), 0, 0, 255,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdLow_Desc")));
Parameters.Add("PadThresholdHigh", new ProcessorParameter(
"PadThresholdHigh",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh"),
typeof(int), 120, 0, 255,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadThresholdHigh_Desc")));
Parameters.Add("PadMorphKernel", new ProcessorParameter(
"PadMorphKernel",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel"),
typeof(int), 5, 1, 31,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadMorphKernel_Desc")));
Parameters.Add("MinPadArea", new ProcessorParameter(
"MinPadArea",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea"),
typeof(int), 200, 10, 1000000,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinPadArea_Desc")));
Parameters.Add("MaxPadArea", new ProcessorParameter(
"MaxPadArea",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea"),
typeof(int), 100000, 100, 10000000,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MaxPadArea_Desc")));
Parameters.Add("PadAspectRatioMin", new ProcessorParameter(
"PadAspectRatioMin",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin"),
typeof(double), 1.2, 0.1, 20.0,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_PadAspectRatioMin_Desc")));
// ── 第二步:空洞检测参数 ──
Parameters.Add("VoidThresholdLow", new ProcessorParameter(
"VoidThresholdLow",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow"),
typeof(int), 128, 0, 255,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdLow_Desc")));
Parameters.Add("VoidThresholdHigh", new ProcessorParameter(
"VoidThresholdHigh",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh"),
typeof(int), 255, 0, 255,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidThresholdHigh_Desc")));
Parameters.Add("MinVoidArea", new ProcessorParameter(
"MinVoidArea",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea"),
typeof(int), 5, 1, 10000,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinVoidArea_Desc")));
Parameters.Add("VoidMergeRadius", new ProcessorParameter(
"VoidMergeRadius",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius"),
typeof(int), 2, 0, 20,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidMergeRadius_Desc")));
Parameters.Add("VoidRateLimit", new ProcessorParameter(
"VoidRateLimit",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit"),
typeof(double), 50.0, 0.0, 100.0,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_VoidRateLimit_Desc")));
Parameters.Add("MinQualifiedPadArea", new ProcessorParameter(
"MinQualifiedPadArea",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea"),
typeof(int), 1000, 0, 1000000,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_MinQualifiedPadArea_Desc")));
Parameters.Add("Thickness", new ProcessorParameter(
"Thickness",
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness"),
typeof(int), 2, 1, 10,
LocalizationHelper.GetString("QfnLeadPadVoidProcessor_Thickness_Desc")));
}
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
{
// 读取参数
string roiMode = GetParameter<string>("RoiMode");
int padBlurSize = GetParameter<int>("PadBlurSize");
int padThreshLow = GetParameter<int>("PadThresholdLow");
int padThreshHigh = GetParameter<int>("PadThresholdHigh");
int padMorphKernel = GetParameter<int>("PadMorphKernel");
int minPadArea = GetParameter<int>("MinPadArea");
int maxPadArea = GetParameter<int>("MaxPadArea");
double padAspectRatioMin = GetParameter<double>("PadAspectRatioMin");
int voidThreshLow = GetParameter<int>("VoidThresholdLow");
int voidThreshHigh = GetParameter<int>("VoidThresholdHigh");
int minVoidArea = GetParameter<int>("MinVoidArea");
int voidMergeRadius = GetParameter<int>("VoidMergeRadius");
double voidRateLimit = GetParameter<double>("VoidRateLimit");
int minQualifiedPadArea = GetParameter<int>("MinQualifiedPadArea");
int thickness = GetParameter<int>("Thickness");
// 确保模糊核为奇数
if (padBlurSize % 2 == 0) padBlurSize++;
if (padMorphKernel % 2 == 0) padMorphKernel++;
OutputData.Clear();
int w = inputImage.Width, h = inputImage.Height;
// ── 构建ROI掩码 ──
Image<Gray, byte>? roiMask = null;
if (roiMode == "Polygon")
{
int polyCount = GetParameter<int>("PolyCount");
if (polyCount >= 3)
{
var pts = new Point[polyCount];
for (int i = 0; i < polyCount; i++)
pts[i] = new Point(GetParameter<int>($"PolyX{i}"), GetParameter<int>($"PolyY{i}"));
roiMask = new Image<Gray, byte>(w, h);
using var vop = new VectorOfPoint(pts);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(roiMask, vvop, 0, new MCvScalar(255), -1);
_logger.Debug("QFN Lead ROI Polygon: {Count} points", polyCount);
}
}
OutputData["RoiMode"] = roiMode;
OutputData["RoiMask"] = roiMask;
_logger.Debug("QfnLeadPadVoid: PadArea=[{Min},{Max}], Blur={Blur}, PadThresh=[{TLow},{THigh}], AspectMin={Asp}",
minPadArea, maxPadArea, padBlurSize, padThreshLow, padThreshHigh, padAspectRatioMin);
// ================================================================
// 第一步:自动检测QFN引脚焊点位置
// ================================================================
var leadPads = DetectLeadPads(inputImage, padBlurSize, padThreshLow, padThreshHigh,
padMorphKernel, minPadArea, maxPadArea, padAspectRatioMin, roiMask);
_logger.Information("第一步完成: 检测到 {Count} 个QFN引脚焊点", leadPads.Count);
if (leadPads.Count == 0)
{
OutputData["QfnLeadResult"] = true;
OutputData["LeadCount"] = 0;
OutputData["LeadPads"] = leadPads;
OutputData["VoidRate"] = 0.0;
OutputData["VoidRateLimit"] = voidRateLimit;
OutputData["Classification"] = "N/A";
OutputData["ResultText"] = "No QFN lead pads detected";
OutputData["Thickness"] = thickness;
OutputData["TotalPadArea"] = 0;
OutputData["TotalVoidArea"] = 0;
OutputData["TotalVoidCount"] = 0;
roiMask?.Dispose();
return inputImage.Clone();
}
// ================================================================
// 第二步:在每个引脚焊点区域内检测空洞
// ================================================================
int totalPadArea = 0;
int totalVoidArea = 0;
int totalVoidCount = 0;
foreach (var pad in leadPads)
{
DetectVoidsInLeadPad(inputImage, pad, voidThreshLow, voidThreshHigh, minVoidArea, voidMergeRadius);
totalPadArea += pad.PadArea;
totalVoidArea += pad.VoidPixels;
totalVoidCount += pad.Voids.Count;
}
double overallVoidRate = totalPadArea > 0 ? (double)totalVoidArea / totalPadArea * 100.0 : 0;
string classification = "PASS";
// 判定:空洞率超标或面积不足均为FAIL
int failCount = 0;
double maxSingleVoidRate = 0;
foreach (var pad in leadPads)
{
if (pad.PadArea < minQualifiedPadArea)
pad.Classification = "FAIL_AREA";
else if (pad.VoidRate > voidRateLimit)
pad.Classification = "FAIL";
else
pad.Classification = "PASS";
if (pad.Classification != "PASS") failCount++;
if (pad.VoidRate > maxSingleVoidRate) maxSingleVoidRate = pad.VoidRate;
}
if (failCount > 0) classification = "FAIL";
_logger.Information("第二步完成: 总空洞率={VoidRate:F1}%, 最大单引脚={MaxRate:F1}%, 不合格={Fail}/{Total}, 判定={Class}",
overallVoidRate, maxSingleVoidRate, failCount, leadPads.Count, classification);
// ── 输出数据 ──
OutputData["QfnLeadResult"] = true;
OutputData["LeadCount"] = leadPads.Count;
OutputData["LeadPads"] = leadPads;
OutputData["VoidRate"] = overallVoidRate;
OutputData["MaxSingleVoidRate"] = maxSingleVoidRate;
OutputData["VoidRateLimit"] = voidRateLimit;
OutputData["TotalPadArea"] = totalPadArea;
OutputData["TotalVoidArea"] = totalVoidArea;
OutputData["TotalVoidCount"] = totalVoidCount;
OutputData["FailCount"] = failCount;
OutputData["Classification"] = classification;
OutputData["Thickness"] = thickness;
OutputData["ResultText"] = $"QFN Lead: {overallVoidRate:F1}% | {classification} | {leadPads.Count} pads | Fail: {failCount}";
roiMask?.Dispose();
return inputImage.Clone();
}
/// <summary>
/// 第一步:自动检测QFN引脚焊点位置
/// 使用双阈值分割 + 形态学 + 轮廓检测 + 面积/长宽比过滤
/// QFN引脚在X-Ray正片中为暗色长条形区域
/// </summary>
private List<QfnLeadPadInfo> DetectLeadPads(
Image<Gray, byte> input, int blurSize,
int threshLow, int threshHigh, int morphKernel,
int minArea, int maxArea, double aspectRatioMin,
Image<Gray, byte>? roiMask)
{
var results = new List<QfnLeadPadInfo>();
int w = input.Width, h = input.Height;
// 高斯模糊降噪
var blurred = new Image<Gray, byte>(w, h);
CvInvoke.GaussianBlur(input, blurred, new Size(blurSize, blurSize), 0);
// 双阈值分割(X-Ray正片:焊点=暗区域,灰度在[threshLow, threshHigh]范围内判为焊点)
var binary = new Image<Gray, byte>(w, h);
unsafe
{
byte* srcPtr = (byte*)blurred.Mat.DataPointer;
byte* dstPtr = (byte*)binary.Mat.DataPointer;
int srcStep = blurred.Mat.Step;
int dstStep = binary.Mat.Step;
for (int y = 0; y < h; y++)
{
byte* srcRow = srcPtr + y * srcStep;
byte* dstRow = dstPtr + y * dstStep;
for (int x = 0; x < w; x++)
{
byte val = srcRow[x];
dstRow[x] = (val >= threshLow && val <= threshHigh) ? (byte)255 : (byte)0;
}
}
}
// 如果有ROI掩码,只保留ROI区域内的结果
if (roiMask != null)
{
CvInvoke.BitwiseAnd(binary, roiMask, binary);
}
// 形态学闭运算填充引脚内部小孔洞
using var kernel = CvInvoke.GetStructuringElement(ElementShape.Rectangle,
new Size(morphKernel, morphKernel), new Point(-1, -1));
CvInvoke.MorphologyEx(binary, binary, MorphOp.Close, kernel, new Point(-1, -1), 2, BorderType.Default, new MCvScalar(0));
// 查找轮廓
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area < minArea || area > maxArea) continue;
// 需要至少5个点才能拟合椭圆/旋转矩形
if (contours[i].Size < 5) continue;
// 最小外接旋转矩形
var minRect = CvInvoke.MinAreaRect(contours[i]);
float rectWidth = Math.Max(minRect.Size.Width, minRect.Size.Height);
float rectHeight = Math.Min(minRect.Size.Width, minRect.Size.Height);
// 长宽比过滤:QFN引脚是长条形,长宽比应大于阈值
if (rectHeight < 1) continue;
double aspectRatio = rectWidth / rectHeight;
if (aspectRatio < aspectRatioMin) continue;
var moments = CvInvoke.Moments(contours[i]);
if (moments.M00 < 1) continue;
results.Add(new QfnLeadPadInfo
{
CenterX = moments.M10 / moments.M00,
CenterY = moments.M01 / moments.M00,
BoundingRotatedRect = minRect,
ContourPoints = contours[i].ToArray(),
PadArea = (int)area,
AspectRatio = aspectRatio
});
}
// 按角度位置排序(从图像中心出发,逆时针排列)
SortLeadPadsByPosition(results, w, h);
blurred.Dispose();
binary.Dispose();
return results;
}
/// <summary>
/// 按引脚在图像中的位置排序(从左上角开始逆时针)
/// 先按所在边分组(上/右/下/左),再在每条边内按位置排序
/// </summary>
private void SortLeadPadsByPosition(List<QfnLeadPadInfo> pads, int imageWidth, int imageHeight)
{
if (pads.Count == 0) return;
double cx = imageWidth / 2.0;
double cy = imageHeight / 2.0;
// 按角度排序(从正上方开始顺时针)
// atan2 返回 [-π, π],调整为从正上方开始
pads.Sort((a, b) =>
{
double angleA = Math.Atan2(a.CenterX - cx, -(a.CenterY - cy));
double angleB = Math.Atan2(b.CenterX - cx, -(b.CenterY - cy));
if (angleA < 0) angleA += 2 * Math.PI;
if (angleB < 0) angleB += 2 * Math.PI;
return angleA.CompareTo(angleB);
});
// 重新编号
for (int i = 0; i < pads.Count; i++)
pads[i].Index = i + 1;
}
/// <summary>
/// 第二步:在单个引脚焊点区域内检测空洞
/// 使用引脚轮廓作为掩码,双阈值分割空洞区域
/// </summary>
private void DetectVoidsInLeadPad(
Image<Gray, byte> input, QfnLeadPadInfo pad,
int voidThreshLow, int voidThreshHigh, int minVoidArea, int mergeRadius)
{
int w = input.Width, h = input.Height;
// 创建该引脚的掩码
var mask = new Image<Gray, byte>(w, h);
using (var vop = new VectorOfPoint(pad.ContourPoints))
using (var vvop = new VectorOfVectorOfPoint(vop))
{
CvInvoke.DrawContours(mask, vvop, 0, new MCvScalar(255), -1);
}
int padPixels = CvInvoke.CountNonZero(mask);
pad.PadArea = padPixels;
// 双阈值分割(正片模式:空洞=亮区域,灰度在[voidThreshLow, voidThreshHigh]范围内判为空洞)
var voidImg = new Image<Gray, byte>(w, h);
unsafe
{
byte* srcPtr = (byte*)input.Mat.DataPointer;
byte* dstPtr = (byte*)voidImg.Mat.DataPointer;
byte* mskPtr = (byte*)mask.Mat.DataPointer;
int srcStep = input.Mat.Step;
int dstStep = voidImg.Mat.Step;
int mskStep = mask.Mat.Step;
for (int y = 0; y < h; y++)
{
byte* srcRow = srcPtr + y * srcStep;
byte* dstRow = dstPtr + y * dstStep;
byte* mskRow = mskPtr + y * mskStep;
for (int x = 0; x < w; x++)
{
if (mskRow[x] > 0)
{
byte val = srcRow[x];
dstRow[x] = (val >= voidThreshLow && val <= voidThreshHigh) ? (byte)255 : (byte)0;
}
}
}
}
// 形态学膨胀合并相邻空洞
if (mergeRadius > 0)
{
int kernelSize = mergeRadius * 2 + 1;
using var kernel = CvInvoke.GetStructuringElement(ElementShape.Ellipse,
new Size(kernelSize, kernelSize), new Point(-1, -1));
CvInvoke.Dilate(voidImg, voidImg, kernel, new Point(-1, -1), 1, BorderType.Default, new MCvScalar(0));
// 与引脚掩码取交集,防止膨胀超出引脚区域
CvInvoke.BitwiseAnd(voidImg, mask, voidImg);
}
// 检测每个空洞的轮廓
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
CvInvoke.FindContours(voidImg, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
int filteredVoidArea = 0;
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area < minVoidArea) continue;
var moments = CvInvoke.Moments(contours[i]);
if (moments.M00 < 1) continue;
filteredVoidArea += (int)Math.Round(area);
pad.Voids.Add(new QfnLeadVoidInfo
{
Index = pad.Voids.Count + 1,
CenterX = moments.M10 / moments.M00,
CenterY = moments.M01 / moments.M00,
Area = area,
AreaPercent = padPixels > 0 ? area / padPixels * 100.0 : 0,
BoundingBox = CvInvoke.BoundingRectangle(contours[i]),
ContourPoints = contours[i].ToArray()
});
}
// 空洞率基于过滤后的轮廓面积计算
pad.VoidPixels = filteredVoidArea;
pad.VoidRate = padPixels > 0 ? (double)filteredVoidArea / padPixels * 100.0 : 0;
// 按面积从大到小排序
pad.Voids.Sort((a, b) => b.Area.CompareTo(a.Area));
for (int i = 0; i < pad.Voids.Count; i++) pad.Voids[i].Index = i + 1;
mask.Dispose();
voidImg.Dispose();
}
}
/// <summary>
/// 单个QFN引脚焊点信息
/// </summary>
public class QfnLeadPadInfo
{
public int Index { get; set; }
public double CenterX { get; set; }
public double CenterY { get; set; }
public RotatedRect BoundingRotatedRect { get; set; }
public Point[] ContourPoints { get; set; } = Array.Empty<Point>();
public int PadArea { get; set; }
public double AspectRatio { get; set; }
public int VoidPixels { get; set; }
public double VoidRate { get; set; }
public string Classification { get; set; } = "N/A";
public List<QfnLeadVoidInfo> Voids { get; set; } = new();
}
/// <summary>
/// 单个引脚内的空洞信息
/// </summary>
public class QfnLeadVoidInfo
{
public int Index { get; set; }
public double CenterX { get; set; }
public double CenterY { get; set; }
public double Area { get; set; }
public double AreaPercent { get; set; }
public Rectangle BoundingBox { get; set; }
public Point[] ContourPoints { get; set; } = Array.Empty<Point>();
}
@@ -125,18 +125,28 @@ public class VoidMeasurementProcessor : ImageProcessorBase
// ── 双阈值分割提取气泡(亮区域) ──
var voidImg = new Image<Gray, byte>(w, h);
byte[,,] srcData = blurred.Data;
byte[,,] dstData = voidImg.Data;
byte[,,] maskData = roiMask.Data;
unsafe
{
byte* srcPtr = (byte*)blurred.Mat.DataPointer;
byte* dstPtr = (byte*)voidImg.Mat.DataPointer;
byte* mskPtr = (byte*)roiMask.Mat.DataPointer;
int srcStep = blurred.Mat.Step;
int dstStep = voidImg.Mat.Step;
int mskStep = roiMask.Mat.Step;
for (int y = 0; y < h; y++)
{
byte* srcRow = srcPtr + y * srcStep;
byte* dstRow = dstPtr + y * dstStep;
byte* mskRow = mskPtr + y * mskStep;
for (int x = 0; x < w; x++)
{
if (maskData[y, x, 0] > 0)
if (mskRow[x] > 0)
{
byte val = srcData[y, x, 0];
dstData[y, x, 0] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0;
byte val = srcRow[x];
dstRow[x] = (val >= minThresh && val <= maxThresh) ? (byte)255 : (byte)0;
}
}
}
}
@@ -23,7 +23,7 @@ namespace XP.ImageProcessing.Processors;
/// </summary>
public class GaussianBlurProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<GammaProcessor>();
private static readonly ILogger _logger = Log.ForContext<GaussianBlurProcessor>();
public GaussianBlurProcessor()
{
@@ -444,8 +444,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
double w = CanvasWidth, h = CanvasHeight;
if (w <= 0 || h <= 0) return;
_crosshairH = new Line { X1 = 0, Y1 = h / 2, X2 = w, Y2 = h / 2, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false };
_crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false };
double thickness = Math.Max(1, Math.Round(Math.Max(w, h) / 1000.0));
_crosshairH = new Line { X1 = 0, Y1 = h / 2, X2 = w, Y2 = h / 2, Stroke = Brushes.Red, StrokeThickness = thickness, Opacity = 0.7, IsHitTestVisible = false };
_crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = thickness, Opacity = 0.7, IsHitTestVisible = false };
mainCanvas.Children.Add(_crosshairH);
mainCanvas.Children.Add(_crosshairV);
}
@@ -460,6 +461,16 @@ namespace XP.ImageProcessing.RoiControl.Controls
#region Measurement
/// <summary>
/// 根据画布尺寸计算自适应线宽
/// </summary>
private double GetAdaptiveThickness(double baseThickness = 1.0)
{
double maxDim = Math.Max(CanvasWidth, CanvasHeight);
double scale = Math.Max(1, Math.Round(maxDim / 1000.0));
return baseThickness * scale;
}
public static readonly DependencyProperty CurrentMeasureModeProperty =
DependencyProperty.Register(nameof(CurrentMeasureMode), typeof(Models.MeasureMode), typeof(PolygonRoiCanvas),
new PropertyMetadata(Models.MeasureMode.None, OnMeasureModeChanged));
@@ -758,12 +769,12 @@ namespace XP.ImageProcessing.RoiControl.Controls
private Models.MeasureGroup CreatePPGroup(Point p1, Point p2)
{
var g = new Models.MeasureGroup { P1 = p1, P2 = p2 };
g.Line = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
g.Line = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
// 使用垂直线段代替圆点
g.PerpLine1 = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = true, Cursor = Cursors.Hand };
g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = true, Cursor = Cursors.Hand };
g.PerpLine1 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand };
g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand };
// 保留圆点以兼容拖拽逻辑(但设为不可见)
g.Dot1 = CreateMDot(Brushes.Red);
@@ -865,7 +876,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_ptlTempDot2 = CreateMDot(Brushes.Lime);
_measureOverlay.Children.Add(_ptlTempDot2);
SetDotPos(_ptlTempDot2, pos);
_ptlTempLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false,
_ptlTempLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false,
X1 = _ptlTempL1.Value.X, Y1 = _ptlTempL1.Value.Y, X2 = pos.X, Y2 = pos.Y };
_measureOverlay.Children.Add(_ptlTempLine);
RaiseMeasureStatusChanged($"点线距 - 直线已定义,请点击测量点");
@@ -891,10 +902,10 @@ namespace XP.ImageProcessing.RoiControl.Controls
private Models.PointToLineGroup CreatePTLGroup(Point l1, Point l2, Point p)
{
var g = new Models.PointToLineGroup { L1 = l1, L2 = l2, P = p };
g.MainLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false,
g.MainLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false,
StrokeDashArray = new DoubleCollection { 4, 2 }, Visibility = Visibility.Collapsed };
g.PerpLine = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = false,
g.PerpLine = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false,
StrokeDashArray = new DoubleCollection { 4, 2 } };
g.FootDot = new Ellipse { Width = 2, Height = 2, Fill = Brushes.Cyan, Stroke = Brushes.White, StrokeThickness = 0.5, IsHitTestVisible = false };
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
@@ -928,7 +939,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_elfTempLine = new Line
{
Stroke = Brushes.Cyan,
StrokeThickness = 1,
StrokeThickness = GetAdaptiveThickness(),
StrokeDashArray = new DoubleCollection { 4, 2 },
IsHitTestVisible = false,
X1 = _elfTempStart.Value.X,
@@ -971,7 +982,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_angleTempADot = CreateMDot(Brushes.Orange);
_measureOverlay.Children.Add(_angleTempADot);
SetDotPos(_angleTempADot, pos);
_angleTempLineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false,
_angleTempLineA = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false,
X1 = _angleTempV.Value.X, Y1 = _angleTempV.Value.Y, X2 = pos.X, Y2 = pos.Y };
_measureOverlay.Children.Add(_angleTempLineA);
RaiseMeasureStatusChanged($"角度测量 - 射线A已定义,请点击射线端点B");
@@ -997,9 +1008,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
private Models.AngleGroup CreateAngleGroup(Point v, Point a, Point b)
{
var g = new Models.AngleGroup { V = v, A = a, B = b };
g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = 1.5, IsHitTestVisible = false };
g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(1.5), IsHitTestVisible = false };
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
g.DotV = CreateMDot(Brushes.Red);
g.DotA = CreateMDot(Brushes.Orange);
@@ -1040,8 +1051,8 @@ namespace XP.ImageProcessing.RoiControl.Controls
g.PathE3 = CreateEllipsePath(Brushes.Yellow, true);
g.PathE4 = CreateEllipsePath(Brushes.Lime, false);
g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = 1, IsHitTestVisible = false };
g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
g.Label = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, Cursor = Cursors.Hand };
g.Label.SetValue(ContextMenuService.IsEnabledProperty, false);
@@ -1078,9 +1089,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
return h;
}
private static Path CreateEllipsePath(Brush stroke, bool dashed)
private Path CreateEllipsePath(Brush stroke, bool dashed)
{
var p = new Path { Stroke = stroke, StrokeThickness = 1.5, IsHitTestVisible = false };
var p = new Path { Stroke = stroke, StrokeThickness = GetAdaptiveThickness(1.5), IsHitTestVisible = false };
if (dashed) p.StrokeDashArray = new DoubleCollection { 4, 2 };
return p;
}
@@ -1164,7 +1175,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
c.Shape = new Ellipse
{
Stroke = isBall ? Brushes.Lime : Brushes.Orange,
StrokeThickness = isBall ? 2 : 1.5,
StrokeThickness = isBall ? GetAdaptiveThickness(2) : GetAdaptiveThickness(1.5),
Fill = Brushes.Transparent,
IsHitTestVisible = false
};
@@ -1552,7 +1563,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_bubbleRoiRect = new Rectangle
{
Stroke = Brushes.Red,
StrokeThickness = 1,
StrokeThickness = GetAdaptiveThickness(),
Fill = Brushes.Transparent,
Visibility = Visibility.Collapsed,
IsHitTestVisible = false
@@ -1609,7 +1620,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_bubbleCircleShape = new Ellipse
{
Stroke = Brushes.Red,
StrokeThickness = 1,
StrokeThickness = GetAdaptiveThickness(),
Fill = Brushes.Transparent,
Visibility = Visibility.Collapsed,
IsHitTestVisible = false
@@ -1638,7 +1649,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
_bubblePolyShape = new Polygon
{
Stroke = Brushes.Red,
StrokeThickness = 1,
StrokeThickness = GetAdaptiveThickness(),
Fill = Brushes.Transparent,
IsHitTestVisible = false
};
+1 -1
View File
@@ -374,7 +374,7 @@ namespace XplorePlane
// {
// Log.Error(ex, "通知相机 ViewModel 失败");
// }
// [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度
//TryConnectCamera();
//try
//{
Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

@@ -0,0 +1,245 @@
using System;
using System.Drawing;
using System.Threading;
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 Serilog;
using XP.Camera;
using XP.Camera.Calibration;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Abstractions.Enums;
namespace XplorePlane.Services.Calibration;
/// <summary>
/// 导航相机标定采集服务实现
/// 读取编码器坐标 + 导航相机拍图 + 图像识别标记中心(亚像素)
/// </summary>
public class NavigationCalibrationCaptureService : ICalibrationCaptureService
{
private static readonly ILogger _logger = Log.ForContext<NavigationCalibrationCaptureService>();
private readonly IMotionSystem _motionSystem;
private readonly ICameraController _navCamera;
private BitmapSource? _lastCapturedImage;
private readonly object _captureLock = new();
private ManualResetEventSlim? _imageReadyEvent;
private bool _livePreviewActive;
public event EventHandler<LiveImageEventArgs>? LiveImageUpdated;
public NavigationCalibrationCaptureService(IMotionSystem motionSystem, ICameraController navCamera)
{
_motionSystem = motionSystem;
_navCamera = navCamera;
}
public bool IsAvailable => _navCamera.IsConnected;
public void StartLivePreview()
{
if (!IsAvailable || _livePreviewActive) return;
_livePreviewActive = true;
_navCamera.ImageGrabbed += OnLiveImageGrabbed;
// 如果相机没在采集,启动采集并触发第一帧
if (!_navCamera.IsGrabbing)
{
_navCamera.StartGrabbing();
_navCamera.ExecuteSoftwareTrigger();
}
_logger.Information("标定实时预览已启动");
}
public void StopLivePreview()
{
_livePreviewActive = false;
_navCamera.ImageGrabbed -= OnLiveImageGrabbed;
_logger.Information("标定实时预览已停止");
}
private void OnLiveImageGrabbed(object? sender, ImageGrabbedEventArgs e)
{
if (!_livePreviewActive) return;
var bmp = ConvertToBitmapSource(e);
if (bmp != null)
{
_lastLiveImage = bmp;
LiveImageUpdated?.Invoke(this, new LiveImageEventArgs(bmp));
}
}
private volatile BitmapSource? _lastLiveImage;
public CaptureResult? CaptureCurrentPoint()
{
if (!IsAvailable)
{
_logger.Warning("采集失败:导航相机未连接");
return null;
}
// 1. 读取编码器坐标
var xAxis = _motionSystem.GetLinearAxis(AxisId.StageX);
var yAxis = _motionSystem.GetLinearAxis(AxisId.StageY);
xAxis.UpdateStatus();
yAxis.UpdateStatus();
double worldX = xAxis.ActualPosition;
double worldY = yAxis.ActualPosition;
// 2. 导航相机拍图
var image = CaptureImage();
if (image == null)
{
_logger.Warning("采集失败:无法获取导航相机图像");
return null;
}
// 3. 图像识别标记中心(亚像素)
var grayImage = BitmapSourceToGray(image);
var detection = DetectMarkerCenter(grayImage);
grayImage.Dispose();
if (detection == null)
{
_logger.Warning("采集失败:未能识别标记点");
return null;
}
_logger.Information("标定点采集成功: Pixel=({Px:F1}, {Py:F1}), World=({Wx:F3}, {Wy:F3})",
detection.Value.Center.X, detection.Value.Center.Y, worldX, worldY);
return new CaptureResult
{
PixelX = detection.Value.Center.X,
PixelY = detection.Value.Center.Y,
WorldX = worldX,
WorldY = worldY,
Image = image,
ContourPoints = detection.Value.Contour
};
}
public BitmapSource? CaptureImage()
{
if (!_navCamera.IsConnected) return null;
// 如果实时预览在运行,直接使用最新一帧
if (_livePreviewActive && _lastLiveImage != null)
return _lastLiveImage;
lock (_captureLock)
{
_lastCapturedImage = null;
_imageReadyEvent = new ManualResetEventSlim(false);
void OnImageGrabbed(object? sender, ImageGrabbedEventArgs e)
{
_lastCapturedImage = ConvertToBitmapSource(e);
_imageReadyEvent?.Set();
}
_navCamera.ImageGrabbed += OnImageGrabbed;
try
{
if (!_navCamera.IsGrabbing)
_navCamera.StartGrabbing();
_navCamera.ExecuteSoftwareTrigger();
// 等待图像到达(超时 3 秒)
if (!_imageReadyEvent.Wait(3000))
{
_logger.Warning("导航相机采集超时");
return null;
}
return _lastCapturedImage;
}
finally
{
_navCamera.ImageGrabbed -= OnImageGrabbed;
_imageReadyEvent?.Dispose();
_imageReadyEvent = null;
}
}
}
/// <summary>
/// 检测图像中标记点的中心(亚像素精度)
/// 支持圆点标记:阈值分割 → 轮廓检测 → 面积过滤 → 亚像素质心
/// </summary>
private (PointF Center, Point[] Contour)? DetectMarkerCenter(Image<Gray, byte> grayImage)
{
int w = grayImage.Width, h = grayImage.Height;
// 高斯模糊降噪
using var blurred = new Image<Gray, byte>(w, h);
CvInvoke.GaussianBlur(grayImage, blurred, new Size(5, 5), 1.0);
// Otsu 自动阈值二值化
using var binary = new Image<Gray, byte>(w, h);
CvInvoke.Threshold(blurred, binary, 0, 255, ThresholdType.Otsu | ThresholdType.Binary);
// 轮廓检测
using var contours = new VectorOfVectorOfPoint();
using var hierarchy = new Mat();
CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple);
// 找到面积最大的轮廓(假设标记是视野中最显著的特征)
double maxArea = 0;
int bestIdx = -1;
double minValidArea = w * h * 0.001; // 最小有效面积:图像面积的 0.1%
double maxValidArea = w * h * 0.5; // 最大有效面积:图像面积的 50%
for (int i = 0; i < contours.Size; i++)
{
double area = CvInvoke.ContourArea(contours[i]);
if (area < minValidArea || area > maxValidArea) continue;
if (area > maxArea)
{
maxArea = area;
bestIdx = i;
}
}
if (bestIdx < 0) return null;
// 亚像素质心计算
var moments = CvInvoke.Moments(contours[bestIdx]);
if (moments.M00 < 1) return null;
double cx = moments.M10 / moments.M00;
double cy = moments.M01 / moments.M00;
var contourPoints = contours[bestIdx].ToArray();
return (new PointF((float)cx, (float)cy), contourPoints);
}
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;
}
private static BitmapSource? ConvertToBitmapSource(ImageGrabbedEventArgs e)
{
if (e.Width <= 0 || e.Height <= 0 || e.PixelData == null) return null;
return PixelConverter.ToBitmapSource(e.PixelData, e.Width, e.Height, e.PixelFormat);
}
}
@@ -36,7 +36,7 @@ namespace XplorePlane.Services
return "图像增强";
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
if (ContainsAny(operatorKey, "RotatedTemplateMatching"))
if (ContainsAny(operatorKey, "RotatedTemplateMatching", "RoiAlignment"))
return "定位识别";
if (ContainsAny(operatorKey, "Mirror", "Rotate", "Grayscale", "Threshold"))
@@ -115,6 +115,8 @@ namespace XplorePlane.Services
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
if (ContainsAny(operatorKey, "RotatedTemplateMatching"))
return "🎯";
if (ContainsAny(operatorKey, "RoiAlignment"))
return "📐";
if (ContainsAny(operatorKey, "Mirror"))
return "↔";
if (ContainsAny(operatorKey, "Rotate"))
@@ -121,7 +121,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
if (_canvas.ROIItems == null)
_canvas.ROIItems = new ObservableCollection<ROIShape>();
_roiShape = new PolygonROI { Color = "Red", IsSelected = true };
_roiShape = new PolygonROI { Color = "Cyan", IsSelected = true };
_canvas.ROIItems.Add(_roiShape);
_canvas.SelectedROI = _roiShape;
@@ -0,0 +1,549 @@
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 QfnLeadPadDetectionViewModel : BindableBase
{
private readonly IMainViewportService _viewportService;
private CncEditorViewModel _cncEditorViewModel;
private BitmapSource _originalImage;
private System.Threading.CancellationTokenSource _debounceCts;
private const int DebounceMs = 300;
private const string QfnLeadPadOperatorKey = "QfnLeadPadVoid";
public QfnLeadPadDetectionViewModel(IMainViewportService viewportService)
{
_viewportService = viewportService;
ExecuteCommand = new DelegateCommand(Execute);
InsertToCncCommand = new DelegateCommand(ExecuteInsertToCnc);
PropertyChanged += OnAnyPropertyChanged;
}
public void SetCncEditorViewModel(CncEditorViewModel cncEditorViewModel)
{
_cncEditorViewModel = cncEditorViewModel;
}
private void OnAnyPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
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());
}
// ── 引脚定位参数 ──
private int _padBlurSize = 5;
public int PadBlurSize { get => _padBlurSize; set => SetProperty(ref _padBlurSize, value); }
private int _padThresholdLow = 0;
public int PadThresholdLow { get => _padThresholdLow; set => SetProperty(ref _padThresholdLow, value); }
private int _padThresholdHigh = 120;
public int PadThresholdHigh { get => _padThresholdHigh; set => SetProperty(ref _padThresholdHigh, value); }
private int _padMorphKernel = 5;
public int PadMorphKernel { get => _padMorphKernel; set => SetProperty(ref _padMorphKernel, value); }
private int _minPadArea = 200;
public int MinPadArea { get => _minPadArea; set => SetProperty(ref _minPadArea, value); }
private int _maxPadArea = 100000;
public int MaxPadArea { get => _maxPadArea; set => SetProperty(ref _maxPadArea, value); }
private double _padAspectRatioMin = 1.2;
public double PadAspectRatioMin { get => _padAspectRatioMin; set => SetProperty(ref _padAspectRatioMin, value); }
// ── 空洞检测参数 ──
private int _voidThresholdLow = 128;
public int VoidThresholdLow { get => _voidThresholdLow; set => SetProperty(ref _voidThresholdLow, value); }
private int _voidThresholdHigh = 255;
public int VoidThresholdHigh { get => _voidThresholdHigh; set => SetProperty(ref _voidThresholdHigh, value); }
private int _minVoidArea = 5;
public int MinVoidArea { get => _minVoidArea; set => SetProperty(ref _minVoidArea, value); }
private int _voidMergeRadius = 2;
public int VoidMergeRadius { get => _voidMergeRadius; set => SetProperty(ref _voidMergeRadius, value); }
private double _voidRateLimit = 50.0;
public double VoidRateLimit { get => _voidRateLimit; set => SetProperty(ref _voidRateLimit, value); }
private int _minQualifiedPadArea = 1000;
public int MinQualifiedPadArea { get => _minQualifiedPadArea; set => SetProperty(ref _minQualifiedPadArea, 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)
{
if (_canvas.ROIItems == null)
_canvas.ROIItems = new ObservableCollection<ROIShape>();
_roiShape = new PolygonROI { Color = "Cyan", IsSelected = true };
_canvas.ROIItems.Add(_roiShape);
_canvas.SelectedROI = _roiShape;
_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);
_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;
}
}
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 ObservableCollection<QfnLeadPadResultItem> 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 QfnLeadPadVoidProcessor();
processor.SetParameter("PadBlurSize", PadBlurSize);
processor.SetParameter("PadThresholdLow", PadThresholdLow);
processor.SetParameter("PadThresholdHigh", PadThresholdHigh);
processor.SetParameter("PadMorphKernel", PadMorphKernel);
processor.SetParameter("MinPadArea", MinPadArea);
processor.SetParameter("MaxPadArea", MaxPadArea);
processor.SetParameter("PadAspectRatioMin", PadAspectRatioMin);
processor.SetParameter("VoidThresholdLow", VoidThresholdLow);
processor.SetParameter("VoidThresholdHigh", VoidThresholdHigh);
processor.SetParameter("MinVoidArea", MinVoidArea);
processor.SetParameter("VoidMergeRadius", VoidMergeRadius);
processor.SetParameter("VoidRateLimit", VoidRateLimit);
processor.SetParameter("MinQualifiedPadArea", MinQualifiedPadArea);
// 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() ?? "--"
: "未检测到引脚";
// 填充结果表格
Results.Clear();
if (output.ContainsKey("LeadPads"))
{
var pads = output["LeadPads"] as List<QfnLeadPadInfo>;
if (pads != null)
{
foreach (var pad in pads)
{
Results.Add(new QfnLeadPadResultItem
{
Index = pad.Index,
CenterX = pad.CenterX.ToString("F1"),
CenterY = pad.CenterY.ToString("F1"),
PadArea = pad.PadArea.ToString(),
VoidRate = $"{pad.VoidRate:F1}%",
VoidCount = pad.Voids.Count,
Classification = pad.Classification
});
}
}
}
ResultImage = RenderResults(grayImage, output);
ShowResultOnOverlay(ResultImage);
grayImage.Dispose();
}
catch (Exception ex)
{
ResultText = $"错误: {ex.Message}";
}
}
private void ExecuteInsertToCnc()
{
if (_cncEditorViewModel == null)
{
MessageBox.Show("CNC 编辑器未就绪,无法插入参数。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
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
{
var allNodes = _cncEditorViewModel.Nodes;
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;
}
var pipeline = targetModuleNode.Pipeline ?? new PipelineModel { Name = targetModuleNode.Name };
var qfnNode = pipeline.Nodes.FirstOrDefault(n =>
string.Equals(n.OperatorKey, QfnLeadPadOperatorKey, StringComparison.OrdinalIgnoreCase));
if (qfnNode == null)
{
qfnNode = new PipelineNodeModel
{
Id = Guid.NewGuid(),
OperatorKey = QfnLeadPadOperatorKey,
Order = pipeline.Nodes.Count,
IsEnabled = true,
Parameters = new Dictionary<string, object>()
};
pipeline.Nodes.Add(qfnNode);
}
var parameters = qfnNode.Parameters;
parameters["PadBlurSize"] = PadBlurSize;
parameters["PadThresholdLow"] = PadThresholdLow;
parameters["PadThresholdHigh"] = PadThresholdHigh;
parameters["PadMorphKernel"] = PadMorphKernel;
parameters["MinPadArea"] = MinPadArea;
parameters["MaxPadArea"] = MaxPadArea;
parameters["PadAspectRatioMin"] = PadAspectRatioMin;
parameters["VoidThresholdLow"] = VoidThresholdLow;
parameters["VoidThresholdHigh"] = VoidThresholdHigh;
parameters["MinVoidArea"] = MinVoidArea;
parameters["VoidMergeRadius"] = VoidMergeRadius;
parameters["VoidRateLimit"] = VoidRateLimit;
parameters["MinQualifiedPadArea"] = MinQualifiedPadArea;
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.UpdatedAt = DateTime.UtcNow;
targetModuleNode.Pipeline = pipeline;
_cncEditorViewModel.SelectedNode = null;
_cncEditorViewModel.SelectedNode = targetModuleNode;
MessageBox.Show(
$"已将QFN引脚检测参数插入到检测模块「{targetModuleNode.Name}」。",
"插入成功", MessageBoxButton.OK, MessageBoxImage.Information);
}
private void ShowResultOnOverlay(BitmapSource resultBmp)
{
if (_canvas == null) return;
RemoveResultOverlay();
if (resultBmp == null) return;
_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)
{
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 BitmapSource RenderResults(Image<Gray, byte> grayImage, IDictionary<string, object> output)
{
if (!output.ContainsKey("QfnLeadResult")) return null;
int leadCount = (int)output["LeadCount"];
if (leadCount == 0) return null;
double voidRate = (double)output["VoidRate"];
double voidRateLimitVal = (double)output["VoidRateLimit"];
string classification = (string)output["Classification"];
var pads = output["LeadPads"] as List<QfnLeadPadInfo>;
var colorImage = new Image<Bgr, byte>(grayImage.Width, grayImage.Height);
CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr);
if (pads != null && pads.Count > 0)
{
// 半透明引脚填充
var overlay = colorImage.Clone();
foreach (var pad in pads)
{
if (pad.ContourPoints.Length > 0)
{
var padColor = pad.Classification == "PASS"
? new MCvScalar(0, 200, 0) // 绿色
: new MCvScalar(0, 0, 220); // 红色
using var vop = new VectorOfPoint(pad.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(overlay, vvop, 0, padColor, -1);
}
}
CvInvoke.AddWeighted(overlay, 0.3, colorImage, 0.7, 0, colorImage);
overlay.Dispose();
// 绘制引脚轮廓 + 编号 + 空洞率
foreach (var pad in pads)
{
var contourColor = pad.Classification == "PASS"
? new MCvScalar(0, 255, 0)
: new MCvScalar(0, 0, 255);
if (pad.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(pad.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(colorImage, vvop, 0, contourColor, 2);
}
// 绘制空洞轮廓
foreach (var v in pad.Voids)
{
if (v.ContourPoints.Length > 0)
{
using var vop = new VectorOfPoint(v.ContourPoints);
using var vvop = new VectorOfVectorOfPoint(vop);
CvInvoke.DrawContours(colorImage, vvop, 0, new MCvScalar(0, 255, 255), 1);
}
}
// 引脚编号和空洞率
CvInvoke.PutText(colorImage, $"#{pad.Index} {pad.VoidRate:F1}%",
new System.Drawing.Point((int)pad.CenterX - 20, (int)pad.CenterY + 5),
FontFace.HersheySimplex, 0.35, contourColor, 1);
}
// 左上角总览
var overallColor = classification == "PASS"
? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255);
CvInvoke.PutText(colorImage,
$"QFN Lead: {voidRate:F1}% | Limit: {voidRateLimitVal:F0}% | {leadCount} pads | {classification}",
new System.Drawing.Point(10, 25),
FontFace.HersheySimplex, 0.5, 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;
}
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));
}
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 QfnLeadPadResultItem
{
public int Index { get; set; }
public string CenterX { get; set; } = "";
public string CenterY { get; set; } = "";
public string PadArea { get; set; } = "";
public string VoidRate { get; set; } = "";
public int VoidCount { get; set; }
public string Classification { get; set; } = "";
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -47,6 +48,10 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
private bool _useSimd = true;
private bool _useSubPixel;
private bool _isModelReady;
private bool _hasReferencePose;
private double _referenceCenterX;
private double _referenceCenterY;
private double _referenceAngle;
public TemplateMatchAssistantViewModel(
IEventAggregator eventAggregator,
@@ -244,6 +249,10 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
}
IsModelReady = true;
_hasReferencePose = true;
_referenceCenterX = rx + rw * 0.5;
_referenceCenterY = ry + rh * 0.5;
_referenceAngle = 0.0;
StatusMessage = $"模板已从 ROI 学习成功({rw}×{rh} 像素)。可保存模型或运行匹配。";
_logger.Info("Template assistant: learned from ROI {0},{1},{2},{3}", rx, ry, rw, rh);
}
@@ -284,8 +293,20 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
IsModelReady = true;
if (TryLoadModelMetadata(dlg.FileName, out var metadata))
{
_hasReferencePose = true;
_referenceCenterX = metadata.ReferencePose.CenterX;
_referenceCenterY = metadata.ReferencePose.CenterY;
_referenceAngle = metadata.ReferencePose.Angle;
StatusMessage = $"已加载模型: {Path.GetFileName(dlg.FileName)}(已读取同名参考位姿 JSON";
}
else
{
_hasReferencePose = false;
StatusMessage = $"已加载模型: {Path.GetFileName(dlg.FileName)}";
}
}
catch (Exception ex)
{
Log.Error(ex, "LoadModel failed");
@@ -312,7 +333,14 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
lock (_matcherLock)
ok = _matcher != null && _matcher.SaveModel(dlg.FileName);
if (ok)
StatusMessage = $"模型已保存: {dlg.FileName}";
{
if (TrySaveModelMetadata(dlg.FileName, out var metadataPath, out var metadataError))
StatusMessage = $"模型已保存: {dlg.FileName};参考位姿已保存: {metadataPath}";
else if (_hasReferencePose)
StatusMessage = $"模型已保存: {dlg.FileName};参考位姿 JSON 保存失败: {metadataError}";
else
StatusMessage = $"模型已保存: {dlg.FileName};未生成参考位姿 JSON(当前无示教基准位姿)。";
}
else
StatusMessage = "模型保存失败。";
}
@@ -485,6 +513,71 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
return image;
}
private bool TrySaveModelMetadata(string modelPath, out string metadataPath, out string? errorMessage)
{
metadataPath = Path.ChangeExtension(modelPath, ".json");
errorMessage = null;
if (!_hasReferencePose)
return false;
try
{
string modelFileName = Path.GetFileName(modelPath);
string templateName = Path.GetFileNameWithoutExtension(modelPath);
var metadata = new TemplateModelMetadata
{
TemplateName = templateName,
ModelFileName = modelFileName,
SavedAt = DateTime.Now,
ReferencePose = new TemplateReferencePose
{
CenterX = _referenceCenterX,
CenterY = _referenceCenterY,
Angle = _referenceAngle
}
};
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(metadataPath, json);
return true;
}
catch (Exception ex)
{
errorMessage = ex.Message;
Log.Error(ex, "Save template metadata failed");
return false;
}
}
private static bool TryLoadModelMetadata(string modelPath, out TemplateModelMetadata metadata)
{
metadata = new TemplateModelMetadata();
try
{
string metadataPath = Path.ChangeExtension(modelPath, ".json");
if (!File.Exists(metadataPath))
return false;
string json = File.ReadAllText(metadataPath);
var parsed = JsonSerializer.Deserialize<TemplateModelMetadata>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (parsed?.ReferencePose == null)
return false;
metadata = parsed;
return true;
}
catch
{
return false;
}
}
public void Dispose()
{
if (_disposed) return;
@@ -505,3 +598,18 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
}
}
}
public sealed class TemplateModelMetadata
{
public string TemplateName { get; set; } = string.Empty;
public string ModelFileName { get; set; } = string.Empty;
public DateTime SavedAt { get; set; }
public TemplateReferencePose ReferencePose { get; set; } = new();
}
public sealed class TemplateReferencePose
{
public double CenterX { get; set; }
public double CenterY { get; set; }
public double Angle { get; set; }
}
+38 -2
View File
@@ -171,6 +171,7 @@ namespace XplorePlane.ViewModels
public DelegateCommand ThroughHoleFillRateMeasureCommand { get; }
public DelegateCommand BgaDetectionCommand { get; }
public DelegateCommand VoidDetectionCommand { get; }
public DelegateCommand QfnLeadPadDetectionCommand { get; }
public DelegateCommand BubbleMeasureCommand { get; }
private bool _isScaleBarVisible;
@@ -402,6 +403,7 @@ namespace XplorePlane.ViewModels
ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure);
BgaDetectionCommand = new DelegateCommand(ExecuteBgaDetection);
VoidDetectionCommand = new DelegateCommand(ExecuteVoidDetection);
QfnLeadPadDetectionCommand = new DelegateCommand(ExecuteQfnLeadPadDetection);
BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure);
// 辅助线命令
@@ -674,14 +676,28 @@ namespace XplorePlane.ViewModels
};
var calibrationDialogService = new XP.Camera.Calibration.DefaultCalibrationDialogService();
var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService);
// 尝试创建采集服务(需要运动系统和导航相机)
XP.Camera.Calibration.ICalibrationCaptureService? captureService = null;
try
{
var motionSystem = Prism.Ioc.ContainerLocator.Current?.Resolve<XP.Hardware.MotionControl.Abstractions.IMotionSystem>();
var navCamera = Prism.Ioc.ContainerLocator.Current?.Resolve<XP.Camera.ICameraController>();
if (motionSystem != null && navCamera != null)
captureService = new Services.Calibration.NavigationCalibrationCaptureService(motionSystem, navCamera);
}
catch { /* 采集服务不可用时降级为手动模式 */ }
var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService, captureService);
var calibrationControl = new XP.Camera.Calibration.Controls.CalibrationControl
{
DataContext = calibrationViewModel
};
calibrationWindow.Content = calibrationControl;
calibrationWindow.ShowDialog();
calibrationWindow.Closed += (s, e) => calibrationViewModel.Cleanup();
calibrationWindow.Owner = System.Windows.Application.Current.MainWindow;
calibrationWindow.Show();
}
private void ExecuteOpenSettings()
@@ -1106,6 +1122,26 @@ namespace XplorePlane.ViewModels
_voidDetectionPanel.Show();
}
private Window _qfnLeadPadDetectionPanel;
private void ExecuteQfnLeadPadDetection()
{
if (!CheckImageLoaded()) return;
_logger.Info("QFN引脚空洞检测功能已触发");
if (_qfnLeadPadDetectionPanel != null && _qfnLeadPadDetectionPanel.IsVisible)
{
_qfnLeadPadDetectionPanel.Activate();
return;
}
_qfnLeadPadDetectionPanel = new Views.ImageProcessing.QfnLeadPadDetectionPanel
{
Owner = System.Windows.Application.Current.MainWindow
};
_qfnLeadPadDetectionPanel.Show();
}
private Window _bubbleMeasurePanel;
private void ExecuteBubbleMeasure()
@@ -0,0 +1,226 @@
<Window
x:Class="XplorePlane.Views.ImageProcessing.QfnLeadPadDetectionPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="QFN引脚空洞检测" Width="780" Height="650"
ResizeMode="CanResize" WindowStartupLocation="CenterOwner"
Topmost="True" ShowInTaskbar="False"
Background="#F5F5F5" FontFamily="Microsoft YaHei UI">
<Window.Resources>
<Style x:Key="IconBtnStyle" TargetType="ButtonBase">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ButtonBase">
<Border x:Name="Bd" Background="#FFFFFF" BorderBrush="#E0E0E0"
BorderThickness="1" CornerRadius="6" Padding="8,6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#EAF2FB" />
<Setter TargetName="Bd" Property="BorderBrush" Value="#B0D4F1" />
</Trigger>
<DataTrigger Binding="{Binding IsChecked, RelativeSource={RelativeSource Self}}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#005FB8" />
<Setter TargetName="Bd" Property="BorderBrush" Value="#004C99" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="#E8E8E8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style x:Key="ParamLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#555" />
<Setter Property="Margin" Value="0,0,0,3" />
</Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#D0D0D0" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="4,3" />
<Setter Property="FontSize" Value="11.5" />
<Style.Resources>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="4" />
</Style>
</Style.Resources>
</Style>
</Window.Resources>
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="290" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 左侧:参数 -->
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto" Margin="0,0,8,0">
<StackPanel>
<!-- 工具栏 -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<ToggleButton IsChecked="{Binding RoiEnabled}" Style="{StaticResource IconBtnStyle}" ToolTip="启用ROI区域" Margin="0,0,6,0">
<Image Source="/Assets/Icons/polygon.png" Width="20" Height="20" />
</ToggleButton>
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding ExecuteCommand}" ToolTip="执行检测" Margin="0,0,6,0">
<Image Source="/Assets/Icons/run32.png" Width="20" Height="20" />
</Button>
<Button Style="{StaticResource IconBtnStyle}" Click="Close_Click" ToolTip="关闭" Margin="0,0,6,0">
<Image Source="/Assets/Icons/ok.png" Width="20" Height="20" />
</Button>
<Button Style="{StaticResource IconBtnStyle}" Command="{Binding InsertToCncCommand}" ToolTip="将当前参数插入到激活的CNC检测模块" Margin="0,0,6,0">
<TextBlock Text="插入CNC" FontSize="11" VerticalAlignment="Center" />
</Button>
</StackPanel>
<!-- 引脚定位参数 -->
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="引脚定位参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
<TextBlock Text="引脚阈值下限" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding PadThresholdLow, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="255" Value="{Binding PadThresholdLow}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="引脚阈值上限" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding PadThresholdHigh, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="255" Value="{Binding PadThresholdHigh}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="最小引脚面积" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding MinPadArea, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="10" Maximum="100000" Value="{Binding MinPadArea}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="最大引脚面积" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding MaxPadArea, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="100" Maximum="1000000" Value="{Binding MaxPadArea}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="最小长宽比" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding PadAspectRatioMin, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0.1" Maximum="20" Value="{Binding PadAspectRatioMin}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="模糊核大小" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding PadBlurSize, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="1" Maximum="31" Value="{Binding PadBlurSize}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="形态学核大小" Style="{StaticResource ParamLabel}" />
<DockPanel>
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding PadMorphKernel, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="1" Maximum="31" Value="{Binding PadMorphKernel}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- 空洞检测参数 -->
<Border Style="{StaticResource CardStyle}">
<StackPanel>
<TextBlock Text="空洞检测参数" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,8" />
<TextBlock Text="空洞阈值下限" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding VoidThresholdLow, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="255" Value="{Binding VoidThresholdLow}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="空洞阈值上限" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding VoidThresholdHigh, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="255" Value="{Binding VoidThresholdHigh}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="最小空洞面积" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding MinVoidArea, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="1" Maximum="10000" Value="{Binding MinVoidArea}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="合并半径" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding VoidMergeRadius, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="20" Value="{Binding VoidMergeRadius}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="空洞率限值(%)" Style="{StaticResource ParamLabel}" />
<DockPanel Margin="0,0,0,6">
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding VoidRateLimit, StringFormat=F1, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="100" Value="{Binding VoidRateLimit}" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="引脚合格面积" Style="{StaticResource ParamLabel}" />
<DockPanel>
<TextBox DockPanel.Dock="Right" Width="55" Text="{Binding MinQualifiedPadArea, UpdateSourceTrigger=PropertyChanged}" VerticalContentAlignment="Center" Margin="6,0,0,0" />
<Slider Minimum="0" Maximum="100000" Value="{Binding MinQualifiedPadArea}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- 结果摘要 -->
<Border Style="{StaticResource CardStyle}">
<TextBlock Text="{Binding ResultText}" FontSize="13" FontWeight="SemiBold" Foreground="#333" TextWrapping="Wrap" />
</Border>
</StackPanel>
</ScrollViewer>
<!-- 右侧:结果表格 -->
<Border Grid.Column="1" Background="White" BorderBrush="#E8E8E8" BorderThickness="1" CornerRadius="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="引脚检测结果" FontWeight="SemiBold" FontSize="12" Margin="12,10,0,6" Foreground="#333" />
<DataGrid Grid.Row="1" ItemsSource="{Binding Results}"
AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" GridLinesVisibility="None"
BorderThickness="0" Background="White"
RowHeight="30" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<DataGrid.ColumnHeaderStyle>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#F8F9FA" />
<Setter Property="Foreground" Value="#666" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Padding" Value="8,6" />
<Setter Property="BorderBrush" Value="#EEE" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
</DataGrid.ColumnHeaderStyle>
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</DataGrid.CellStyle>
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="White" />
<Style.Triggers>
<Trigger Property="AlternationIndex" Value="1">
<Setter Property="Background" Value="#FAFBFC" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#EDF4FC" />
</Trigger>
</Style.Triggers>
</Style>
</DataGrid.RowStyle>
<DataGrid.Columns>
<DataGridTextColumn Header="#" Binding="{Binding Index}" Width="35" />
<DataGridTextColumn Header="中心X" Binding="{Binding CenterX}" Width="60" />
<DataGridTextColumn Header="中心Y" Binding="{Binding CenterY}" Width="60" />
<DataGridTextColumn Header="面积(px)" Binding="{Binding PadArea}" Width="70" />
<DataGridTextColumn Header="空洞率" Binding="{Binding VoidRate}" Width="65" />
<DataGridTextColumn Header="空洞数" Binding="{Binding VoidCount}" Width="55" />
<DataGridTextColumn Header="判定" Binding="{Binding Classification}" Width="55" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
</Grid>
</Window>
@@ -0,0 +1,59 @@
using System.Windows;
using Prism.Ioc;
using XP.ImageProcessing.RoiControl.Controls;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.ImageProcessing;
namespace XplorePlane.Views.ImageProcessing
{
public partial class QfnLeadPadDetectionPanel : Window
{
public QfnLeadPadDetectionPanel()
{
InitializeComponent();
var viewportService = ContainerLocator.Current?.Resolve<IMainViewportService>();
DataContext = new QfnLeadPadDetectionViewModel(viewportService);
Loaded += (s, e) =>
{
var mainWin = Owner as MainWindow;
if (mainWin != null)
{
var canvas = FindChild<PolygonRoiCanvas>(mainWin);
if (DataContext is QfnLeadPadDetectionViewModel vm)
vm.SetCanvas(canvas);
}
if (DataContext is QfnLeadPadDetectionViewModel qfnVm && Owner?.DataContext is ViewModels.MainViewModel mainVm)
{
var cncEditorField = mainVm.GetType().GetField("_cncEditorViewModel",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (cncEditorField?.GetValue(mainVm) is CncEditorViewModel cncEditor)
qfnVm.SetCncEditorViewModel(cncEditor);
}
};
Closed += (s, e) =>
{
if (DataContext is QfnLeadPadDetectionViewModel vm)
vm.RestoreContextMenu();
};
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
int count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < count; i++)
{
var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
if (child is T t) return t;
var result = FindChild<T>(child);
if (result != null) return result;
}
return null;
}
}
}
+9 -2
View File
@@ -181,11 +181,11 @@
</StackPanel>
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="灰度"
telerik:ScreenTip.Title="线灰度"
Command="{Binding GrayscaleCommand}"
Size="Medium"
SmallImage="/Assets/Icons/film-darken.png"
Text="灰度" />
Text="线灰度" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="黑底检测"
Command="{Binding BlackBackgroundDetectionCommand}"
@@ -415,6 +415,13 @@
Content="孔隙检测"
Size="Large"
SmallImage="/Assets/Icons/Pores.png" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="自动检测QFN引脚焊点并逐引脚测量空洞率"
telerik:ScreenTip.Title="QFN引脚检测"
Command="{Binding QfnLeadPadDetectionCommand}"
Content="QFN检测"
Size="Large"
SmallImage="/Assets/Icons/QFN.png" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
@@ -187,6 +187,14 @@ namespace XplorePlane.Views
ToggleLineProfile();
}, Prism.Events.ThreadOption.UIThread);
// 图像变化时重绘线灰度
var canvasWidthDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty(
PolygonRoiCanvas.CanvasWidthProperty, typeof(PolygonRoiCanvas));
canvasWidthDesc?.AddValueChanged(RoiCanvas, (s, e) =>
{
if (_lineProfileEnabled) RedrawLineProfile();
});
// 白底检测:进入ROI绘制模式
ea2?.GetEvent<WhiteBackgroundDetectionEvent>().Subscribe(() =>
{
@@ -256,8 +264,12 @@ namespace XplorePlane.Views
// 参考线默认在图像中间
_profileLineY = RoiCanvas.CanvasHeight / 2;
// 根据图像分辨率自适应线条粗细
double maxDim = Math.Max(RoiCanvas.CanvasWidth, RoiCanvas.CanvasHeight);
double lineThickness = Math.Max(1, Math.Round(maxDim / 1000.0));
// 创建参考线(红色水平线,可拖动)
// 用透明粗线作为命中区域,叠加1px红线显示
// 用透明粗线作为命中区域,叠加红线显示
_profileRefLine = new System.Windows.Shapes.Line
{
X1 = 0,
@@ -265,7 +277,7 @@ namespace XplorePlane.Views
X2 = RoiCanvas.CanvasWidth,
Y2 = _profileLineY,
Stroke = System.Windows.Media.Brushes.Transparent,
StrokeThickness = 7, // 上下3px命中区域
StrokeThickness = lineThickness + 6, // 上下命中区域
IsHitTestVisible = true,
Cursor = System.Windows.Input.Cursors.SizeNS
};
@@ -276,7 +288,7 @@ namespace XplorePlane.Views
X2 = RoiCanvas.CanvasWidth,
Y2 = _profileLineY,
Stroke = System.Windows.Media.Brushes.Red,
StrokeThickness = 1,
StrokeThickness = lineThickness,
IsHitTestVisible = false
};
_profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown;
@@ -289,7 +301,7 @@ namespace XplorePlane.Views
_profileCurve = new System.Windows.Shapes.Polyline
{
Stroke = System.Windows.Media.Brushes.Red,
StrokeThickness = 1,
StrokeThickness = lineThickness,
IsHitTestVisible = false
};
canvas.Children.Add(_profileCurve);
@@ -393,6 +405,64 @@ namespace XplorePlane.Views
SetStatus($"行灰度分布 | Y={row} | 均值={rowPixels.Select(b => (double)b).Average():F1} | 最大={rowPixels.Max()} | 最小={rowPixels.Min()}");
}
/// <summary>
/// 图像变化时重绘线灰度(移除旧元素,重新创建)
/// </summary>
private void RedrawLineProfile()
{
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
if (canvas == null) return;
// 移除旧元素
if (_profileRefLine != null) { canvas.Children.Remove(_profileRefLine); _profileRefLine = null; }
if (_profileRefLineVisible != null) { canvas.Children.Remove(_profileRefLineVisible); _profileRefLineVisible = null; }
if (_profileCurve != null) { canvas.Children.Remove(_profileCurve); _profileCurve = null; }
// 重新计算参考线位置(保持相对比例或重置到中间)
_profileLineY = RoiCanvas.CanvasHeight / 2;
// 根据新图像分辨率自适应线条粗细
double maxDim = Math.Max(RoiCanvas.CanvasWidth, RoiCanvas.CanvasHeight);
double lineThickness = Math.Max(1, Math.Round(maxDim / 1000.0));
_profileRefLine = new System.Windows.Shapes.Line
{
X1 = 0,
Y1 = _profileLineY,
X2 = RoiCanvas.CanvasWidth,
Y2 = _profileLineY,
Stroke = System.Windows.Media.Brushes.Transparent,
StrokeThickness = lineThickness + 6,
IsHitTestVisible = true,
Cursor = System.Windows.Input.Cursors.SizeNS
};
_profileRefLineVisible = new System.Windows.Shapes.Line
{
X1 = 0,
Y1 = _profileLineY,
X2 = RoiCanvas.CanvasWidth,
Y2 = _profileLineY,
Stroke = System.Windows.Media.Brushes.Red,
StrokeThickness = lineThickness,
IsHitTestVisible = false
};
_profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown;
_profileRefLine.MouseMove += ProfileLine_MouseMove;
_profileRefLine.MouseLeftButtonUp += ProfileLine_MouseUp;
canvas.Children.Add(_profileRefLineVisible);
canvas.Children.Add(_profileRefLine);
_profileCurve = new System.Windows.Shapes.Polyline
{
Stroke = System.Windows.Media.Brushes.Red,
StrokeThickness = lineThickness,
IsHitTestVisible = false
};
canvas.Children.Add(_profileCurve);
UpdateLineProfile();
}
#endregion
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)