Merge branch 'Develop/XP' into turbo-002-cnc
This commit is contained in:
@@ -29,6 +29,7 @@ bld/
|
|||||||
[Ll]ogs/
|
[Ll]ogs/
|
||||||
lib/
|
lib/
|
||||||
XP.ImageProcessing/
|
XP.ImageProcessing/
|
||||||
|
XP.ImageProcessing.SmokeTest/
|
||||||
ImageProcessing.sln
|
ImageProcessing.sln
|
||||||
|
|
||||||
# 排除 Libs 目录中的 DLL 和 PDB 文件(但保留目录结构)
|
# 排除 Libs 目录中的 DLL 和 PDB 文件(但保留目录结构)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:cal="clr-namespace:XP.Camera.Calibration"
|
xmlns:cal="clr-namespace:XP.Camera.Calibration"
|
||||||
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
|
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
|
||||||
|
xmlns:roi="clr-namespace:XP.ImageProcessing.RoiControl.Controls;assembly=XP.ImageProcessing.RoiControl"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DesignHeight="850"
|
d:DesignHeight="850"
|
||||||
d:DesignWidth="1400">
|
d:DesignWidth="1400">
|
||||||
@@ -88,6 +89,20 @@
|
|||||||
<Image Source="/XP.Camera;component/Calibration/Resources/外部导入.png" Width="24" Height="24" />
|
<Image Source="/XP.Camera;component/Calibration/Resources/外部导入.png" Width="24" Height="24" />
|
||||||
</Button.Tag>
|
</Button.Tag>
|
||||||
</Button>
|
</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}"
|
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=CalibrationExecute}"
|
||||||
Command="{Binding CalibrateCommand}" FontFamily="Segoe UI"
|
Command="{Binding CalibrateCommand}" FontFamily="Segoe UI"
|
||||||
Style="{StaticResource ToolbarButtonStyle}">
|
Style="{StaticResource ToolbarButtonStyle}">
|
||||||
@@ -113,6 +128,32 @@
|
|||||||
VerticalAlignment="Center" FontFamily="Segoe UI"
|
VerticalAlignment="Center" FontFamily="Segoe UI"
|
||||||
IsChecked="{Binding ShowWorldCoordinates}"
|
IsChecked="{Binding ShowWorldCoordinates}"
|
||||||
Margin="10,0,0,0" FontSize="13" Foreground="{StaticResource TextColor}" />
|
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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -136,6 +177,7 @@
|
|||||||
|
|
||||||
<DataGrid Grid.Row="1" AutoGenerateColumns="False" CanUserAddRows="True"
|
<DataGrid Grid.Row="1" AutoGenerateColumns="False" CanUserAddRows="True"
|
||||||
ItemsSource="{Binding CalibrationPoints}"
|
ItemsSource="{Binding CalibrationPoints}"
|
||||||
|
SelectedItem="{Binding SelectedPoint}"
|
||||||
HeadersVisibility="Column" GridLinesVisibility="All"
|
HeadersVisibility="Column" GridLinesVisibility="All"
|
||||||
FontFamily="Segoe UI"
|
FontFamily="Segoe UI"
|
||||||
BorderBrush="{StaticResource BorderColor}" BorderThickness="1">
|
BorderBrush="{StaticResource BorderColor}" BorderThickness="1">
|
||||||
@@ -159,7 +201,8 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</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"
|
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
|
||||||
Margin="12,0,12,12" Padding="12" MinHeight="80">
|
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;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Media.Imaging;
|
||||||
using XP.Camera.Calibration.ViewModels;
|
using XP.Camera.Calibration.ViewModels;
|
||||||
using WpfBrushes = System.Windows.Media.Brushes;
|
using XP.ImageProcessing.RoiControl.Controls;
|
||||||
using WpfColor = System.Windows.Media.Color;
|
|
||||||
|
|
||||||
namespace XP.Camera.Calibration.Controls;
|
namespace XP.Camera.Calibration.Controls;
|
||||||
|
|
||||||
@@ -23,82 +22,42 @@ public partial class CalibrationControl : UserControl
|
|||||||
if (DataContext is CalibrationViewModel viewModel)
|
if (DataContext is CalibrationViewModel viewModel)
|
||||||
{
|
{
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
_viewModel.ImageLoadedRequested += (s, e) =>
|
|
||||||
{
|
|
||||||
imageCanvas.ReferenceImage = _viewModel.ImageSource;
|
|
||||||
imageCanvas.RoiCanvas.Children.Clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
imageCanvas.CanvasRightMouseUp += ImageCanvas_RightMouseUp;
|
|
||||||
imageCanvas.CanvasMouseWheel += ImageCanvas_MouseWheel;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ImageCanvas_MouseWheel(object? sender, MouseWheelEventArgs e)
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_viewModel?.CurrentImage == null) return;
|
if (e.PropertyName == nameof(CalibrationViewModel.OverlayImage))
|
||||||
|
|
||||||
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);
|
UpdateDetectionOverlay();
|
||||||
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)
|
private void UpdateDetectionOverlay()
|
||||||
{
|
{
|
||||||
imageCanvas.RoiCanvas.Children.Clear();
|
if (_viewModel?.OverlayImage == null)
|
||||||
|
|
||||||
var ellipse = new System.Windows.Shapes.Ellipse
|
|
||||||
{
|
{
|
||||||
Width = 10, Height = 10,
|
roiCanvas.ClearDetectionOverlay();
|
||||||
Stroke = WpfBrushes.Red, StrokeThickness = 2,
|
return;
|
||||||
Fill = WpfBrushes.Transparent
|
}
|
||||||
|
|
||||||
|
var overlayCanvas = new Canvas
|
||||||
|
{
|
||||||
|
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
|
var image = new System.Windows.Controls.Image
|
||||||
{
|
{
|
||||||
Text = $"P:({imageX:F0},{imageY:F0})",
|
Source = _viewModel.OverlayImage,
|
||||||
Foreground = WpfBrushes.Red, FontSize = 12,
|
Width = _viewModel.OverlayImage.PixelWidth,
|
||||||
Background = new System.Windows.Media.SolidColorBrush(WpfColor.FromArgb(180, 255, 255, 255))
|
Height = _viewModel.OverlayImage.PixelHeight,
|
||||||
|
IsHitTestVisible = false
|
||||||
};
|
};
|
||||||
Canvas.SetLeft(pixelText, imageX + 10);
|
|
||||||
Canvas.SetTop(pixelText, imageY - 20);
|
|
||||||
imageCanvas.RoiCanvas.Children.Add(pixelText);
|
|
||||||
|
|
||||||
if (_viewModel?.ShowWorldCoordinates == true)
|
overlayCanvas.Children.Add(image);
|
||||||
{
|
roiCanvas.SetDetectionOverlayCanvas(overlayCanvas);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:cal="clr-namespace:XP.Camera.Calibration"
|
xmlns:cal="clr-namespace:XP.Camera.Calibration"
|
||||||
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
|
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
|
||||||
|
xmlns:roi="clr-namespace:XP.ImageProcessing.RoiControl.Controls;assembly=XP.ImageProcessing.RoiControl"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
d:DesignHeight="900"
|
d:DesignHeight="900"
|
||||||
d:DesignWidth="1600">
|
d:DesignWidth="1600">
|
||||||
@@ -168,7 +169,8 @@
|
|||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</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"
|
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
|
||||||
Margin="12,0,8,12" Padding="12" Height="70">
|
Margin="12,0,8,12" Padding="12" Height="70">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|||||||
@@ -1,46 +1,13 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
|
||||||
using XP.Camera.Calibration.ViewModels;
|
using XP.Camera.Calibration.ViewModels;
|
||||||
|
|
||||||
namespace XP.Camera.Calibration.Controls;
|
namespace XP.Camera.Calibration.Controls;
|
||||||
|
|
||||||
public partial class ChessboardCalibrationControl : UserControl
|
public partial class ChessboardCalibrationControl : UserControl
|
||||||
{
|
{
|
||||||
private ChessboardCalibrationViewModel? _viewModel;
|
|
||||||
|
|
||||||
public ChessboardCalibrationControl()
|
public ChessboardCalibrationControl()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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
|
public class CalibrationViewModel : BindableBase
|
||||||
{
|
{
|
||||||
private readonly ICalibrationDialogService _dialogService;
|
private readonly ICalibrationDialogService _dialogService;
|
||||||
|
private readonly ICalibrationCaptureService? _captureService;
|
||||||
private readonly CalibrationProcessor _calibrator = new();
|
private readonly CalibrationProcessor _calibrator = new();
|
||||||
private Image<Bgr, byte>? _currentImage;
|
private Image<Bgr, byte>? _currentImage;
|
||||||
private static readonly ILogger _logger = Log.ForContext<CalibrationViewModel>();
|
private static readonly ILogger _logger = Log.ForContext<CalibrationViewModel>();
|
||||||
private BitmapSource? _imageSource;
|
private BitmapSource? _imageSource;
|
||||||
|
private BitmapSource? _frozenImage;
|
||||||
private string _statusText = Res.CalibrationStatusReady;
|
private string _statusText = Res.CalibrationStatusReady;
|
||||||
private bool _showWorldCoordinates;
|
private bool _showWorldCoordinates;
|
||||||
|
private bool _isLiveView = true;
|
||||||
|
|
||||||
public CalibrationViewModel(ICalibrationDialogService dialogService)
|
public CalibrationViewModel(ICalibrationDialogService dialogService, ICalibrationCaptureService? captureService = null)
|
||||||
{
|
{
|
||||||
_dialogService = dialogService;
|
_dialogService = dialogService;
|
||||||
|
_captureService = captureService;
|
||||||
CalibrationPoints = new ObservableCollection<CalibrationProcessor.CalibrationPoint>();
|
CalibrationPoints = new ObservableCollection<CalibrationProcessor.CalibrationPoint>();
|
||||||
|
|
||||||
LoadImageCommand = new DelegateCommand(LoadImage);
|
LoadImageCommand = new DelegateCommand(LoadImage);
|
||||||
@@ -29,6 +33,16 @@ public class CalibrationViewModel : BindableBase
|
|||||||
.ObservesProperty(() => CalibrationPoints.Count);
|
.ObservesProperty(() => CalibrationPoints.Count);
|
||||||
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
|
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
|
||||||
LoadCalibrationCommand = new DelegateCommand(LoadCalibration);
|
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; }
|
public ObservableCollection<CalibrationProcessor.CalibrationPoint> CalibrationPoints { get; }
|
||||||
@@ -39,6 +53,14 @@ public class CalibrationViewModel : BindableBase
|
|||||||
set => SetProperty(ref _imageSource, value);
|
set => SetProperty(ref _imageSource, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BitmapSource? _overlayImage;
|
||||||
|
/// <summary>叠加层图像(显示检测到的轮廓和中心点)</summary>
|
||||||
|
public BitmapSource? OverlayImage
|
||||||
|
{
|
||||||
|
get => _overlayImage;
|
||||||
|
set => SetProperty(ref _overlayImage, value);
|
||||||
|
}
|
||||||
|
|
||||||
public string StatusText
|
public string StatusText
|
||||||
{
|
{
|
||||||
get => _statusText;
|
get => _statusText;
|
||||||
@@ -51,11 +73,48 @@ public class CalibrationViewModel : BindableBase
|
|||||||
set => SetProperty(ref _showWorldCoordinates, value);
|
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 LoadImageCommand { get; }
|
||||||
public DelegateCommand LoadCsvCommand { get; }
|
public DelegateCommand LoadCsvCommand { get; }
|
||||||
public DelegateCommand CalibrateCommand { get; }
|
public DelegateCommand CalibrateCommand { get; }
|
||||||
public DelegateCommand SaveCalibrationCommand { get; }
|
public DelegateCommand SaveCalibrationCommand { get; }
|
||||||
public DelegateCommand LoadCalibrationCommand { 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()
|
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 PointF ConvertPixelToWorld(PointF pixel) => _calibrator.PixelToWorld(pixel);
|
||||||
|
|
||||||
public Image<Bgr, byte>? CurrentImage => _currentImage;
|
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 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)
|
private static BitmapSource MatToBitmapSource(Mat mat)
|
||||||
{
|
{
|
||||||
using var bitmap = mat.ToBitmap();
|
using var bitmap = mat.ToBitmap();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<PackageReference Include="Prism.Wpf" Version="9.0.537" />
|
<PackageReference Include="Prism.Wpf" Version="9.0.537" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<ProjectReference Include="..\XP.Common\XP.Common.csproj" />
|
<ProjectReference Include="..\XP.Common\XP.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\XP.ImageProcessing.RoiControl\XP.ImageProcessing.RoiControl.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</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">
|
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
|
||||||
<value>Angle Measurement</value>
|
<value>Angle Measurement</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -2008,4 +2052,108 @@ Reprojection error: {1:F4} pixels</value>
|
|||||||
<value>Histogram — No data</value>
|
<value>Histogram — No data</value>
|
||||||
<comment>ImageHistogramControl - Placeholder text when no image data</comment>
|
<comment>ImageHistogramControl - Placeholder text when no image data</comment>
|
||||||
</data>
|
</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 > 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>
|
</root>
|
||||||
@@ -1573,6 +1573,50 @@
|
|||||||
<value>已训练模型文件路径(.tmmodel)。若存在则直接加载跳过学习;若不存在则从模板学习后自动保存。</value>
|
<value>已训练模型文件路径(.tmmodel)。若存在则直接加载跳过学习;若不存在则从模板学习后自动保存。</value>
|
||||||
</data>
|
</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 -->
|
<!-- AngleMeasurementProcessor -->
|
||||||
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
|
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
|
||||||
<value>角度测量</value>
|
<value>角度测量</value>
|
||||||
@@ -2041,4 +2085,108 @@
|
|||||||
<value>直方图 — 暂无数据</value>
|
<value>直方图 — 暂无数据</value>
|
||||||
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
|
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
|
||||||
</data>
|
</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引脚为长条形,长宽比>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>
|
</root>
|
||||||
@@ -1545,6 +1545,50 @@
|
|||||||
<value>未找到 TemplateMatchLib.dll,请先编译 C++ DLL 工程。</value>
|
<value>未找到 TemplateMatchLib.dll,请先编译 C++ DLL 工程。</value>
|
||||||
</data>
|
</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">
|
<data name="AngleMeasurementProcessor_Name" xml:space="preserve">
|
||||||
<value>角度测量</value>
|
<value>角度测量</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -2002,4 +2046,108 @@
|
|||||||
<value>直方图 — 暂无数据</value>
|
<value>直方图 — 暂无数据</value>
|
||||||
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
|
<comment>ImageHistogramControl - 无图像输入时的提示文本 | Placeholder text when no image data</comment>
|
||||||
</data>
|
</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引脚为长条形,长宽比>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>
|
</root>
|
||||||
@@ -1885,4 +1885,102 @@
|
|||||||
<value>直方圖 — 暫無資料</value>
|
<value>直方圖 — 暫無資料</value>
|
||||||
<comment>ImageHistogramControl - 無圖像輸入時的提示文字 | Placeholder text when no image data</comment>
|
<comment>ImageHistogramControl - 無圖像輸入時的提示文字 | Placeholder text when no image data</comment>
|
||||||
</data>
|
</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引腳為長條形,長寬比>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>
|
</root>
|
||||||
@@ -18,4 +18,17 @@ public sealed class AlignmentRecipe
|
|||||||
/// <summary>变换为整型顶点,供检测算子注入。</summary>
|
/// <summary>变换为整型顶点,供检测算子注入。</summary>
|
||||||
public (int X, int Y)[] TransformRoiToInt(Pose2D measuredPose)
|
public (int X, int Y)[] TransformRoiToInt(Pose2D measuredPose)
|
||||||
=> RoiAlignment.TransformPolygonToInt(RoiPoints, ReferencePose, 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)
|
if (useOtsu)
|
||||||
{
|
{
|
||||||
// 使用Otsu算法
|
|
||||||
CvInvoke.Threshold(inputImage, result, minThreshold, 255, ThresholdType.Otsu);
|
CvInvoke.Threshold(inputImage, result, minThreshold, 255, ThresholdType.Otsu);
|
||||||
_logger.Debug("Process: UseOtsu = true");
|
_logger.Debug("Process: UseOtsu = true");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 双阈值分割:介于MinThreshold和MaxThreshold之间的为前景(255),其他为背景(0)
|
|
||||||
byte[,,] inputData = inputImage.Data;
|
|
||||||
byte[,,] outputData = result.Data;
|
|
||||||
|
|
||||||
int height = inputImage.Height;
|
int height = inputImage.Height;
|
||||||
int width = inputImage.Width;
|
int width = inputImage.Width;
|
||||||
|
|
||||||
for (int y = 0; y < height; y++)
|
unsafe
|
||||||
{
|
{
|
||||||
for (int x = 0; x < width; x++)
|
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 pixelValue = inputData[y, x, 0];
|
byte* srcRow = srcPtr + y * srcStep;
|
||||||
outputData[y, x, 0] = (pixelValue >= minThreshold && pixelValue <= maxThreshold)
|
byte* dstRow = dstPtr + y * dstStep;
|
||||||
? (byte)255
|
for (int x = 0; x < width; x++)
|
||||||
: (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 tileSize = 8;
|
||||||
int width = inputImage.Width;
|
int width = inputImage.Width;
|
||||||
int height = inputImage.Height;
|
int height = inputImage.Height;
|
||||||
|
byte[,,] srcData = inputImage.Data;
|
||||||
|
|
||||||
|
// 计算分块数
|
||||||
int tilesX = (width + tileSize - 1) / tileSize;
|
int tilesX = (width + tileSize - 1) / tileSize;
|
||||||
int tilesY = (height + 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 ty = 0; ty < tilesY; ty++)
|
||||||
{
|
{
|
||||||
for (int tx = 0; tx < tilesX; tx++)
|
for (int tx = 0; tx < tilesX; tx++)
|
||||||
{
|
{
|
||||||
int x = tx * tileSize;
|
int x0 = tx * actualTileW;
|
||||||
int y = ty * tileSize;
|
int y0 = ty * actualTileH;
|
||||||
int w = Math.Min(tileSize, width - x);
|
int x1 = Math.Min(x0 + actualTileW, width);
|
||||||
int h = Math.Min(tileSize, height - y);
|
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 hist = new int[256];
|
||||||
var tile = inputImage.Copy();
|
for (int y = y0; y < y1; y++)
|
||||||
inputImage.ROI = System.Drawing.Rectangle.Empty;
|
for (int x = x0; x < x1; x++)
|
||||||
|
hist[srcData[y, x, 0]]++;
|
||||||
|
|
||||||
var equalizedTile = new Image<Gray, byte>(tile.Size);
|
// Clip limit 裁剪并重新分配
|
||||||
CvInvoke.EqualizeHist(tile, equalizedTile);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int avgInc = excess / 256;
|
||||||
|
int remainder = excess - avgInc * 256;
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
hist[i] += avgInc + (i < remainder ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
result.ROI = roi;
|
// 构建 CDF 映射表
|
||||||
equalizedTile.CopyTo(result);
|
int sum = 0;
|
||||||
result.ROI = System.Drawing.Rectangle.Empty;
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
tile.Dispose();
|
sum += hist[i];
|
||||||
equalizedTile.Dispose();
|
luts[ty, tx, i] = (byte)Math.Clamp(sum * 255 / tilePixels, 0, 255);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_logger.Debug("ApplyCLAHE");
|
|
||||||
|
// 双线性插值生成结果
|
||||||
|
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;
|
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.LbX,
|
||||||
result.LbY,
|
result.LbY,
|
||||||
tolerancePixels);
|
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);
|
var voidImg = new Image<Gray, byte>(w, h);
|
||||||
byte[,,] srcData = blurred.Data;
|
|
||||||
byte[,,] dstData = voidImg.Data;
|
|
||||||
byte[,,] maskData = roiMask.Data;
|
|
||||||
|
|
||||||
for (int y = 0; y < h; y++)
|
unsafe
|
||||||
{
|
{
|
||||||
for (int x = 0; x < w; x++)
|
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++)
|
||||||
{
|
{
|
||||||
if (maskData[y, x, 0] > 0)
|
byte* srcRow = srcPtr + y * srcStep;
|
||||||
|
byte* dstRow = dstPtr + y * dstStep;
|
||||||
|
byte* mskRow = mskPtr + y * mskStep;
|
||||||
|
for (int x = 0; x < w; x++)
|
||||||
{
|
{
|
||||||
byte val = srcData[y, x, 0];
|
if (mskRow[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>
|
/// </summary>
|
||||||
public class GaussianBlurProcessor : ImageProcessorBase
|
public class GaussianBlurProcessor : ImageProcessorBase
|
||||||
{
|
{
|
||||||
private static readonly ILogger _logger = Log.ForContext<GammaProcessor>();
|
private static readonly ILogger _logger = Log.ForContext<GaussianBlurProcessor>();
|
||||||
|
|
||||||
public GaussianBlurProcessor()
|
public GaussianBlurProcessor()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -458,8 +458,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
double w = CanvasWidth, h = CanvasHeight;
|
double w = CanvasWidth, h = CanvasHeight;
|
||||||
if (w <= 0 || h <= 0) return;
|
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 };
|
double thickness = Math.Max(1, Math.Round(Math.Max(w, h) / 1000.0));
|
||||||
_crosshairV = new Line { X1 = w / 2, Y1 = 0, X2 = w / 2, Y2 = h, Stroke = Brushes.Red, StrokeThickness = 1, Opacity = 0.7, IsHitTestVisible = false };
|
_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(_crosshairH);
|
||||||
mainCanvas.Children.Add(_crosshairV);
|
mainCanvas.Children.Add(_crosshairV);
|
||||||
}
|
}
|
||||||
@@ -474,6 +475,16 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
|
|
||||||
#region Measurement
|
#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 =
|
public static readonly DependencyProperty CurrentMeasureModeProperty =
|
||||||
DependencyProperty.Register(nameof(CurrentMeasureMode), typeof(Models.MeasureMode), typeof(PolygonRoiCanvas),
|
DependencyProperty.Register(nameof(CurrentMeasureMode), typeof(Models.MeasureMode), typeof(PolygonRoiCanvas),
|
||||||
new PropertyMetadata(Models.MeasureMode.None, OnMeasureModeChanged));
|
new PropertyMetadata(Models.MeasureMode.None, OnMeasureModeChanged));
|
||||||
@@ -772,12 +783,12 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
private Models.MeasureGroup CreatePPGroup(Point p1, Point p2)
|
private Models.MeasureGroup CreatePPGroup(Point p1, Point p2)
|
||||||
{
|
{
|
||||||
var g = new Models.MeasureGroup { P1 = p1, P2 = 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.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.PerpLine1 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand };
|
||||||
g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = 1, IsHitTestVisible = true, Cursor = Cursors.Hand };
|
g.PerpLine2 = new Line { Stroke = Brushes.Yellow, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = true, Cursor = Cursors.Hand };
|
||||||
|
|
||||||
// 保留圆点以兼容拖拽逻辑(但设为不可见)
|
// 保留圆点以兼容拖拽逻辑(但设为不可见)
|
||||||
g.Dot1 = CreateMDot(Brushes.Red);
|
g.Dot1 = CreateMDot(Brushes.Red);
|
||||||
@@ -879,7 +890,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_ptlTempDot2 = CreateMDot(Brushes.Lime);
|
_ptlTempDot2 = CreateMDot(Brushes.Lime);
|
||||||
_measureOverlay.Children.Add(_ptlTempDot2);
|
_measureOverlay.Children.Add(_ptlTempDot2);
|
||||||
SetDotPos(_ptlTempDot2, pos);
|
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 };
|
X1 = _ptlTempL1.Value.X, Y1 = _ptlTempL1.Value.Y, X2 = pos.X, Y2 = pos.Y };
|
||||||
_measureOverlay.Children.Add(_ptlTempLine);
|
_measureOverlay.Children.Add(_ptlTempLine);
|
||||||
RaiseMeasureStatusChanged($"点线距 - 直线已定义,请点击测量点");
|
RaiseMeasureStatusChanged($"点线距 - 直线已定义,请点击测量点");
|
||||||
@@ -905,10 +916,10 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
private Models.PointToLineGroup CreatePTLGroup(Point l1, Point l2, Point p)
|
private Models.PointToLineGroup CreatePTLGroup(Point l1, Point l2, Point p)
|
||||||
{
|
{
|
||||||
var g = new Models.PointToLineGroup { L1 = l1, L2 = l2, P = p };
|
var g = new Models.PointToLineGroup { L1 = l1, L2 = l2, P = p };
|
||||||
g.MainLine = 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 = 1, IsHitTestVisible = false,
|
g.ExtLine = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false,
|
||||||
StrokeDashArray = new DoubleCollection { 4, 2 }, Visibility = Visibility.Collapsed };
|
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 } };
|
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.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 };
|
g.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
|
||||||
@@ -942,7 +953,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_elfTempLine = new Line
|
_elfTempLine = new Line
|
||||||
{
|
{
|
||||||
Stroke = Brushes.Cyan,
|
Stroke = Brushes.Cyan,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = GetAdaptiveThickness(),
|
||||||
StrokeDashArray = new DoubleCollection { 4, 2 },
|
StrokeDashArray = new DoubleCollection { 4, 2 },
|
||||||
IsHitTestVisible = false,
|
IsHitTestVisible = false,
|
||||||
X1 = _elfTempStart.Value.X,
|
X1 = _elfTempStart.Value.X,
|
||||||
@@ -985,7 +996,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_angleTempADot = CreateMDot(Brushes.Orange);
|
_angleTempADot = CreateMDot(Brushes.Orange);
|
||||||
_measureOverlay.Children.Add(_angleTempADot);
|
_measureOverlay.Children.Add(_angleTempADot);
|
||||||
SetDotPos(_angleTempADot, pos);
|
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 };
|
X1 = _angleTempV.Value.X, Y1 = _angleTempV.Value.Y, X2 = pos.X, Y2 = pos.Y };
|
||||||
_measureOverlay.Children.Add(_angleTempLineA);
|
_measureOverlay.Children.Add(_angleTempLineA);
|
||||||
RaiseMeasureStatusChanged($"角度测量 - 射线A已定义,请点击射线端点B");
|
RaiseMeasureStatusChanged($"角度测量 - 射线A已定义,请点击射线端点B");
|
||||||
@@ -1011,9 +1022,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
private Models.AngleGroup CreateAngleGroup(Point v, Point a, Point b)
|
private Models.AngleGroup CreateAngleGroup(Point v, Point a, Point b)
|
||||||
{
|
{
|
||||||
var g = new Models.AngleGroup { V = v, A = a, B = b };
|
var g = new Models.AngleGroup { V = v, A = a, B = b };
|
||||||
g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
|
g.LineA = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
|
||||||
g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, IsHitTestVisible = false };
|
g.LineB = new Line { Stroke = Brushes.Lime, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
|
||||||
g.Arc = new Path { Stroke = Brushes.Yellow, StrokeThickness = 1.5, 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.Label = new TextBlock { Foreground = Brushes.Yellow, FontSize = 13, FontWeight = FontWeights.Bold, IsHitTestVisible = false };
|
||||||
g.DotV = CreateMDot(Brushes.Red);
|
g.DotV = CreateMDot(Brushes.Red);
|
||||||
g.DotA = CreateMDot(Brushes.Orange);
|
g.DotA = CreateMDot(Brushes.Orange);
|
||||||
@@ -1054,8 +1065,8 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
g.PathE3 = CreateEllipsePath(Brushes.Yellow, true);
|
g.PathE3 = CreateEllipsePath(Brushes.Yellow, true);
|
||||||
g.PathE4 = CreateEllipsePath(Brushes.Lime, false);
|
g.PathE4 = CreateEllipsePath(Brushes.Lime, false);
|
||||||
|
|
||||||
g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = 1, IsHitTestVisible = false };
|
g.FullLine = new Line { Stroke = Brushes.Red, StrokeThickness = GetAdaptiveThickness(), IsHitTestVisible = false };
|
||||||
g.FillLine = new Line { Stroke = Brushes.Lime, StrokeThickness = 1, 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 = new TextBlock { FontSize = 13, FontWeight = FontWeights.Bold, Cursor = Cursors.Hand };
|
||||||
g.Label.SetValue(ContextMenuService.IsEnabledProperty, false);
|
g.Label.SetValue(ContextMenuService.IsEnabledProperty, false);
|
||||||
@@ -1092,9 +1103,9 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
return h;
|
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 };
|
if (dashed) p.StrokeDashArray = new DoubleCollection { 4, 2 };
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1189,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
c.Shape = new Ellipse
|
c.Shape = new Ellipse
|
||||||
{
|
{
|
||||||
Stroke = isBall ? Brushes.Lime : Brushes.Orange,
|
Stroke = isBall ? Brushes.Lime : Brushes.Orange,
|
||||||
StrokeThickness = isBall ? 2 : 1.5,
|
StrokeThickness = isBall ? GetAdaptiveThickness(2) : GetAdaptiveThickness(1.5),
|
||||||
Fill = Brushes.Transparent,
|
Fill = Brushes.Transparent,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
};
|
};
|
||||||
@@ -1566,7 +1577,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_bubbleRoiRect = new Rectangle
|
_bubbleRoiRect = new Rectangle
|
||||||
{
|
{
|
||||||
Stroke = Brushes.Red,
|
Stroke = Brushes.Red,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = GetAdaptiveThickness(),
|
||||||
Fill = Brushes.Transparent,
|
Fill = Brushes.Transparent,
|
||||||
Visibility = Visibility.Collapsed,
|
Visibility = Visibility.Collapsed,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
@@ -1623,7 +1634,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_bubbleCircleShape = new Ellipse
|
_bubbleCircleShape = new Ellipse
|
||||||
{
|
{
|
||||||
Stroke = Brushes.Red,
|
Stroke = Brushes.Red,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = GetAdaptiveThickness(),
|
||||||
Fill = Brushes.Transparent,
|
Fill = Brushes.Transparent,
|
||||||
Visibility = Visibility.Collapsed,
|
Visibility = Visibility.Collapsed,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
@@ -1652,7 +1663,7 @@ namespace XP.ImageProcessing.RoiControl.Controls
|
|||||||
_bubblePolyShape = new Polygon
|
_bubblePolyShape = new Polygon
|
||||||
{
|
{
|
||||||
Stroke = Brushes.Red,
|
Stroke = Brushes.Red,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = GetAdaptiveThickness(),
|
||||||
Fill = Brushes.Transparent,
|
Fill = Brushes.Transparent,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ namespace XP.Scan.Services
|
|||||||
}
|
}
|
||||||
catch (SocketException ex)
|
catch (SocketException ex)
|
||||||
{
|
{
|
||||||
_logger.Error($"重建通知发送失败(Socket) | Reconstruction notify failed: {ex.Message}");
|
_logger.Error(ex, "重建通知发送失败(Socket) | Reconstruction notify failed: {0}", ex.Message);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Error($"重建通知发送异常 | Reconstruction notify error: {ex.Message}");
|
_logger.Error(ex, "重建通知发送异常 | Reconstruction notify error: {0}", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ namespace XplorePlane
|
|||||||
// {
|
// {
|
||||||
// Log.Error(ex, "通知相机 ViewModel 失败");
|
// Log.Error(ex, "通知相机 ViewModel 失败");
|
||||||
// }
|
// }
|
||||||
// [DEV] 导航相机连接已屏蔽,开发阶段跳过以加快启动速度
|
|
||||||
//TryConnectCamera();
|
//TryConnectCamera();
|
||||||
//try
|
//try
|
||||||
//{
|
//{
|
||||||
@@ -392,12 +392,12 @@ namespace XplorePlane
|
|||||||
//catch (Exception ex)
|
//catch (Exception ex)
|
||||||
//{
|
//{
|
||||||
// Log.Error(ex, "Failed to notify the camera ViewModel");
|
// Log.Error(ex, "Failed to notify the camera ViewModel");
|
||||||
//}
|
// }
|
||||||
|
|
||||||
//if (_cameraError != null)
|
// if (_cameraError != null)
|
||||||
//{
|
//{
|
||||||
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
|
// HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
//}
|
// }
|
||||||
};
|
};
|
||||||
|
|
||||||
return shell;
|
return shell;
|
||||||
|
|||||||
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 "图像增强";
|
return "图像增强";
|
||||||
|
|
||||||
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
|
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
|
||||||
if (ContainsAny(operatorKey, "RotatedTemplateMatching"))
|
if (ContainsAny(operatorKey, "RotatedTemplateMatching", "RoiAlignment"))
|
||||||
return "定位识别";
|
return "定位识别";
|
||||||
|
|
||||||
if (ContainsAny(operatorKey, "Mirror", "Rotate", "Grayscale", "Threshold"))
|
if (ContainsAny(operatorKey, "Mirror", "Rotate", "Grayscale", "Threshold"))
|
||||||
@@ -115,6 +115,8 @@ namespace XplorePlane.Services
|
|||||||
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
|
// 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate
|
||||||
if (ContainsAny(operatorKey, "RotatedTemplateMatching"))
|
if (ContainsAny(operatorKey, "RotatedTemplateMatching"))
|
||||||
return "🎯";
|
return "🎯";
|
||||||
|
if (ContainsAny(operatorKey, "RoiAlignment"))
|
||||||
|
return "📐";
|
||||||
if (ContainsAny(operatorKey, "Mirror"))
|
if (ContainsAny(operatorKey, "Mirror"))
|
||||||
return "↔";
|
return "↔";
|
||||||
if (ContainsAny(operatorKey, "Rotate"))
|
if (ContainsAny(operatorKey, "Rotate"))
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ namespace XplorePlane.ViewModels.ImageProcessing
|
|||||||
if (_canvas.ROIItems == null)
|
if (_canvas.ROIItems == null)
|
||||||
_canvas.ROIItems = new ObservableCollection<ROIShape>();
|
_canvas.ROIItems = new ObservableCollection<ROIShape>();
|
||||||
|
|
||||||
_roiShape = new PolygonROI { Color = "Red", IsSelected = true };
|
_roiShape = new PolygonROI { Color = "Cyan", IsSelected = true };
|
||||||
_canvas.ROIItems.Add(_roiShape);
|
_canvas.ROIItems.Add(_roiShape);
|
||||||
_canvas.SelectedROI = _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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
@@ -47,6 +48,10 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
|||||||
private bool _useSimd = true;
|
private bool _useSimd = true;
|
||||||
private bool _useSubPixel;
|
private bool _useSubPixel;
|
||||||
private bool _isModelReady;
|
private bool _isModelReady;
|
||||||
|
private bool _hasReferencePose;
|
||||||
|
private double _referenceCenterX;
|
||||||
|
private double _referenceCenterY;
|
||||||
|
private double _referenceAngle;
|
||||||
|
|
||||||
public TemplateMatchAssistantViewModel(
|
public TemplateMatchAssistantViewModel(
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
@@ -244,6 +249,10 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsModelReady = true;
|
IsModelReady = true;
|
||||||
|
_hasReferencePose = true;
|
||||||
|
_referenceCenterX = rx + rw * 0.5;
|
||||||
|
_referenceCenterY = ry + rh * 0.5;
|
||||||
|
_referenceAngle = 0.0;
|
||||||
StatusMessage = $"模板已从 ROI 学习成功({rw}×{rh} 像素)。可保存模型或运行匹配。";
|
StatusMessage = $"模板已从 ROI 学习成功({rw}×{rh} 像素)。可保存模型或运行匹配。";
|
||||||
_logger.Info("Template assistant: learned from ROI {0},{1},{2},{3}", rx, ry, rw, rh);
|
_logger.Info("Template assistant: learned from ROI {0},{1},{2},{3}", rx, ry, rw, rh);
|
||||||
}
|
}
|
||||||
@@ -284,7 +293,19 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
|||||||
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
|
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
|
||||||
|
|
||||||
IsModelReady = true;
|
IsModelReady = true;
|
||||||
StatusMessage = $"已加载模型: {Path.GetFileName(dlg.FileName)}";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -312,7 +333,14 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
|||||||
lock (_matcherLock)
|
lock (_matcherLock)
|
||||||
ok = _matcher != null && _matcher.SaveModel(dlg.FileName);
|
ok = _matcher != null && _matcher.SaveModel(dlg.FileName);
|
||||||
if (ok)
|
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
|
else
|
||||||
StatusMessage = "模型保存失败。";
|
StatusMessage = "模型保存失败。";
|
||||||
}
|
}
|
||||||
@@ -485,6 +513,71 @@ public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
|
|||||||
return image;
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ namespace XplorePlane.ViewModels
|
|||||||
public DelegateCommand ThroughHoleFillRateMeasureCommand { get; }
|
public DelegateCommand ThroughHoleFillRateMeasureCommand { get; }
|
||||||
public DelegateCommand BgaDetectionCommand { get; }
|
public DelegateCommand BgaDetectionCommand { get; }
|
||||||
public DelegateCommand VoidDetectionCommand { get; }
|
public DelegateCommand VoidDetectionCommand { get; }
|
||||||
|
public DelegateCommand QfnLeadPadDetectionCommand { get; }
|
||||||
public DelegateCommand BubbleMeasureCommand { get; }
|
public DelegateCommand BubbleMeasureCommand { get; }
|
||||||
|
|
||||||
private bool _isScaleBarVisible;
|
private bool _isScaleBarVisible;
|
||||||
@@ -424,6 +425,7 @@ namespace XplorePlane.ViewModels
|
|||||||
ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure);
|
ThroughHoleFillRateMeasureCommand = new DelegateCommand(ExecuteThroughHoleFillRateMeasure);
|
||||||
BgaDetectionCommand = new DelegateCommand(ExecuteBgaDetection);
|
BgaDetectionCommand = new DelegateCommand(ExecuteBgaDetection);
|
||||||
VoidDetectionCommand = new DelegateCommand(ExecuteVoidDetection);
|
VoidDetectionCommand = new DelegateCommand(ExecuteVoidDetection);
|
||||||
|
QfnLeadPadDetectionCommand = new DelegateCommand(ExecuteQfnLeadPadDetection);
|
||||||
BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure);
|
BubbleMeasureCommand = new DelegateCommand(ExecuteBubbleMeasure);
|
||||||
|
|
||||||
// 辅助线命令
|
// 辅助线命令
|
||||||
@@ -713,14 +715,28 @@ namespace XplorePlane.ViewModels
|
|||||||
};
|
};
|
||||||
|
|
||||||
var calibrationDialogService = new XP.Camera.Calibration.DefaultCalibrationDialogService();
|
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
|
var calibrationControl = new XP.Camera.Calibration.Controls.CalibrationControl
|
||||||
{
|
{
|
||||||
DataContext = calibrationViewModel
|
DataContext = calibrationViewModel
|
||||||
};
|
};
|
||||||
|
|
||||||
calibrationWindow.Content = calibrationControl;
|
calibrationWindow.Content = calibrationControl;
|
||||||
calibrationWindow.ShowDialog();
|
calibrationWindow.Closed += (s, e) => calibrationViewModel.Cleanup();
|
||||||
|
calibrationWindow.Owner = System.Windows.Application.Current.MainWindow;
|
||||||
|
calibrationWindow.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ExecuteOpenSettings()
|
private void ExecuteOpenSettings()
|
||||||
@@ -1182,6 +1198,26 @@ namespace XplorePlane.ViewModels
|
|||||||
_voidDetectionPanel.Show();
|
_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 Window _bubbleMeasurePanel;
|
||||||
|
|
||||||
private void ExecuteBubbleMeasure()
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,11 +182,11 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<telerik:RadRibbonButton
|
<telerik:RadRibbonButton
|
||||||
telerik:ScreenTip.Title="灰度"
|
telerik:ScreenTip.Title="线灰度"
|
||||||
Command="{Binding GrayscaleCommand}"
|
Command="{Binding GrayscaleCommand}"
|
||||||
Size="Medium"
|
Size="Medium"
|
||||||
SmallImage="/Assets/Icons/film-darken.png"
|
SmallImage="/Assets/Icons/film-darken.png"
|
||||||
Text="灰度" />
|
Text="线灰度" />
|
||||||
<telerik:RadRibbonButton
|
<telerik:RadRibbonButton
|
||||||
telerik:ScreenTip.Title="黑底检测"
|
telerik:ScreenTip.Title="黑底检测"
|
||||||
Command="{Binding BlackBackgroundDetectionCommand}"
|
Command="{Binding BlackBackgroundDetectionCommand}"
|
||||||
@@ -416,6 +416,13 @@
|
|||||||
Content="孔隙检测"
|
Content="孔隙检测"
|
||||||
Size="Large"
|
Size="Large"
|
||||||
SmallImage="/Assets/Icons/Pores.png" />
|
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:RadRibbonGroup>
|
||||||
</telerik:RadRibbonTab>
|
</telerik:RadRibbonTab>
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ namespace XplorePlane.Views
|
|||||||
ToggleLineProfile();
|
ToggleLineProfile();
|
||||||
}, Prism.Events.ThreadOption.UIThread);
|
}, Prism.Events.ThreadOption.UIThread);
|
||||||
|
|
||||||
|
// 图像变化时重绘线灰度
|
||||||
|
var canvasWidthDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty(
|
||||||
|
PolygonRoiCanvas.CanvasWidthProperty, typeof(PolygonRoiCanvas));
|
||||||
|
canvasWidthDesc?.AddValueChanged(RoiCanvas, (s, e) =>
|
||||||
|
{
|
||||||
|
if (_lineProfileEnabled) RedrawLineProfile();
|
||||||
|
});
|
||||||
|
|
||||||
// 白底检测:进入ROI绘制模式
|
// 白底检测:进入ROI绘制模式
|
||||||
ea2?.GetEvent<WhiteBackgroundDetectionEvent>().Subscribe(() =>
|
ea2?.GetEvent<WhiteBackgroundDetectionEvent>().Subscribe(() =>
|
||||||
{
|
{
|
||||||
@@ -256,8 +264,12 @@ namespace XplorePlane.Views
|
|||||||
// 参考线默认在图像中间
|
// 参考线默认在图像中间
|
||||||
_profileLineY = RoiCanvas.CanvasHeight / 2;
|
_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
|
_profileRefLine = new System.Windows.Shapes.Line
|
||||||
{
|
{
|
||||||
X1 = 0,
|
X1 = 0,
|
||||||
@@ -265,7 +277,7 @@ namespace XplorePlane.Views
|
|||||||
X2 = RoiCanvas.CanvasWidth,
|
X2 = RoiCanvas.CanvasWidth,
|
||||||
Y2 = _profileLineY,
|
Y2 = _profileLineY,
|
||||||
Stroke = System.Windows.Media.Brushes.Transparent,
|
Stroke = System.Windows.Media.Brushes.Transparent,
|
||||||
StrokeThickness = 7, // 上下3px命中区域
|
StrokeThickness = lineThickness + 6, // 上下命中区域
|
||||||
IsHitTestVisible = true,
|
IsHitTestVisible = true,
|
||||||
Cursor = System.Windows.Input.Cursors.SizeNS
|
Cursor = System.Windows.Input.Cursors.SizeNS
|
||||||
};
|
};
|
||||||
@@ -276,7 +288,7 @@ namespace XplorePlane.Views
|
|||||||
X2 = RoiCanvas.CanvasWidth,
|
X2 = RoiCanvas.CanvasWidth,
|
||||||
Y2 = _profileLineY,
|
Y2 = _profileLineY,
|
||||||
Stroke = System.Windows.Media.Brushes.Red,
|
Stroke = System.Windows.Media.Brushes.Red,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = lineThickness,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
};
|
};
|
||||||
_profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown;
|
_profileRefLine.MouseLeftButtonDown += ProfileLine_MouseDown;
|
||||||
@@ -289,7 +301,7 @@ namespace XplorePlane.Views
|
|||||||
_profileCurve = new System.Windows.Shapes.Polyline
|
_profileCurve = new System.Windows.Shapes.Polyline
|
||||||
{
|
{
|
||||||
Stroke = System.Windows.Media.Brushes.Red,
|
Stroke = System.Windows.Media.Brushes.Red,
|
||||||
StrokeThickness = 1,
|
StrokeThickness = lineThickness,
|
||||||
IsHitTestVisible = false
|
IsHitTestVisible = false
|
||||||
};
|
};
|
||||||
canvas.Children.Add(_profileCurve);
|
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()}");
|
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
|
#endregion
|
||||||
|
|
||||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||||
|
|||||||
Reference in New Issue
Block a user