diff --git a/XP.ImageProcessing.Processors/检测分析/BgaVoidRateProcessor.cs b/XP.ImageProcessing.Processors/检测分析/BgaVoidRateProcessor.cs
index cb1fede..450d0f1 100644
--- a/XP.ImageProcessing.Processors/检测分析/BgaVoidRateProcessor.cs
+++ b/XP.ImageProcessing.Processors/检测分析/BgaVoidRateProcessor.cs
@@ -216,9 +216,6 @@ public class BgaVoidRateProcessor : ImageProcessorBase
OutputData["Thickness"] = thickness;
OutputData["ResultText"] = $"Void: {overallVoidRate:F1}% | {classification} | BGA×{bgaResults.Count}";
- // 渲染带标注的彩色结果图像
- OutputData["RenderedResultImage"] = RenderAnnotatedResult(inputImage, bgaResults, voidLimit, thickness);
-
roiMask?.Dispose();
return inputImage.Clone();
}
@@ -375,67 +372,6 @@ public class BgaVoidRateProcessor : ImageProcessorBase
mask.Dispose();
voidImg.Dispose();
}
-
- ///
- /// 渲染带标注的彩色结果图像(轮廓、编号、气泡填充、总览信息)
- ///
- private Image RenderAnnotatedResult(Image grayImage, List bgaResults, double voidLimit, int thickness)
- {
- var colorImage = new Image(grayImage.Width, grayImage.Height);
- CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr);
-
- if (bgaResults.Count == 0)
- return colorImage;
-
- // 半透明气泡填充
- var overlay = colorImage.Clone();
- foreach (var bga in bgaResults)
- {
- var fillColor = new MCvScalar(0, 200, 255);
- foreach (var v in bga.Voids)
- {
- if (v.ContourPoints.Length > 0)
- {
- using var vop = new VectorOfPoint(v.ContourPoints);
- using var vvop = new VectorOfVectorOfPoint(vop);
- CvInvoke.DrawContours(overlay, vvop, 0, fillColor, -1);
- }
- }
- }
- CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage);
- overlay.Dispose();
-
- // 绘制焊球轮廓 + 编号
- int ngCount = 0;
- foreach (var bga in bgaResults)
- {
- var bgaColor = bga.Classification == "PASS"
- ? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255);
- if (bga.Classification != "PASS") ngCount++;
-
- if (bga.ContourPoints.Length > 0)
- {
- using var vop = new VectorOfPoint(bga.ContourPoints);
- using var vvop = new VectorOfVectorOfPoint(vop);
- CvInvoke.DrawContours(colorImage, vvop, 0, bgaColor, thickness);
- }
-
- var bbox = CvInvoke.BoundingRectangle(new VectorOfPoint(bga.ContourPoints));
- CvInvoke.PutText(colorImage, $"#{bga.Index}",
- new Point(bbox.X + bbox.Width / 2 - 10, bbox.Bottom + 16),
- FontFace.HersheySimplex, 0.45, new MCvScalar(255, 100, 0), 2);
- }
-
- // 左上角总览
- int okCount = bgaResults.Count - ngCount;
- var overallColor = ngCount > 0 ? new MCvScalar(0, 0, 255) : new MCvScalar(0, 255, 0);
- CvInvoke.PutText(colorImage,
- $"Total: {bgaResults.Count} | OK: {okCount} | NG: {ngCount}",
- new Point(10, 25),
- FontFace.HersheySimplex, 0.55, overallColor, 2);
-
- return colorImage;
- }
}
///
diff --git a/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs b/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs
index 19355a1..b63d591 100644
--- a/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs
+++ b/XP.ImageProcessing.Processors/检测分析/VoidMeasurementProcessor.cs
@@ -207,65 +207,12 @@ public class VoidMeasurementProcessor : ImageProcessorBase
OutputData["Voids"] = voids;
OutputData["ResultText"] = $"Void: {voidRate:F1}% | {classification} | {voids.Count} voids | ROI: {roiArea}px";
- // 渲染带标注的彩色结果图像
- OutputData["RenderedResultImage"] = RenderAnnotatedResult(inputImage, voids, voidRate, voidLimit, classification);
-
blurred.Dispose();
voidImg.Dispose();
roiMask.Dispose();
return inputImage.Clone();
}
-
- ///
- /// 渲染带标注的彩色结果图像(轮廓、编号、半透明填充、总览信息)
- ///
- private Image RenderAnnotatedResult(Image grayImage, List voids, double voidRate, double voidLimit, string classification)
- {
- var colorImage = new Image(grayImage.Width, grayImage.Height);
- CvInvoke.CvtColor(grayImage, colorImage, ColorConversion.Gray2Bgr);
-
- if (voids.Count == 0)
- return colorImage;
-
- // 半透明气泡填充
- var overlay = colorImage.Clone();
- foreach (var v in voids)
- {
- if (v.ContourPoints.Length > 0)
- {
- using var vop = new VectorOfPoint(v.ContourPoints);
- using var vvop = new VectorOfVectorOfPoint(vop);
- CvInvoke.DrawContours(overlay, vvop, 0, new MCvScalar(0, 200, 255), -1);
- }
- }
- CvInvoke.AddWeighted(overlay, 0.4, colorImage, 0.6, 0, colorImage);
- overlay.Dispose();
-
- // 绘制轮廓 + 编号
- foreach (var v in 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, $"#{v.Index}",
- new Point((int)v.CenterX - 8, (int)v.CenterY + 5),
- FontFace.HersheySimplex, 0.35, new MCvScalar(255, 100, 0), 1);
- }
-
- // 左上角总览
- var overallColor = classification == "PASS"
- ? new MCvScalar(0, 255, 0) : new MCvScalar(0, 0, 255);
- CvInvoke.PutText(colorImage,
- $"Void: {voidRate:F1}% | Limit: {voidLimit:F0}% | {voids.Count} voids | {classification}",
- new Point(10, 25),
- FontFace.HersheySimplex, 0.5, overallColor, 2);
-
- return colorImage;
- }
}
///
diff --git a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
index 8b101e1..ee9872b 100644
--- a/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
+++ b/XP.ImageProcessing.RoiControl/Controls/PolygonRoiCanvas.xaml.cs
@@ -671,6 +671,38 @@ namespace XP.ImageProcessing.RoiControl.Controls
if (element != null && mainCanvas.Children.Contains(element))
mainCanvas.Children.Remove(element);
}
+
+ // ── 检测结果叠加层 ──
+ private Canvas _detectionOverlay;
+
+ ///
+ /// 设置检测结果叠加层 Canvas(由外部构建好后传入)。
+ ///
+ public void SetDetectionOverlayCanvas(Canvas overlayCanvas)
+ {
+ ClearDetectionOverlay();
+
+ if (overlayCanvas == null) return;
+
+ _detectionOverlay = overlayCanvas;
+ _detectionOverlay.IsHitTestVisible = false;
+ _detectionOverlay.SetBinding(Canvas.WidthProperty, new System.Windows.Data.Binding("CanvasWidth") { Source = this });
+ _detectionOverlay.SetBinding(Canvas.HeightProperty, new System.Windows.Data.Binding("CanvasHeight") { Source = this });
+
+ // 插入到 backgroundImage 之后(索引1),在 ROI 和测量层之下
+ int insertIndex = System.Math.Min(1, mainCanvas.Children.Count);
+ mainCanvas.Children.Insert(insertIndex, _detectionOverlay);
+ }
+
+ /// 清除检测结果叠加层
+ public void ClearDetectionOverlay()
+ {
+ if (_detectionOverlay != null)
+ {
+ mainCanvas.Children.Remove(_detectionOverlay);
+ _detectionOverlay = null;
+ }
+ }
public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count + _frGroups.Count + _bgaGroups.Count;
// ── 点击分发 ──
diff --git a/XplorePlane/Services/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs
index 904be67..19b5898 100644
--- a/XplorePlane/Services/Cnc/CncExecutionService.cs
+++ b/XplorePlane/Services/Cnc/CncExecutionService.cs
@@ -718,6 +718,16 @@ namespace XplorePlane.Services.Cnc
nodeResult.Status = InspectionNodeStatus.Succeeded;
_mainViewportService?.SetCncResultImage(resultImage, $"CNC \u8282\u70b9\u7ed3\u679c\uff1a{inspectionNode.Name}");
}
+
+ // 推送检测结果叠加层数据(轮廓、标注信息)供 UI 分层绘制
+ if (execResult.LastStepOutputData != null)
+ {
+ var lastOperatorKey = inspectionNode.Pipeline.Nodes
+ .Where(n => n.IsEnabled)
+ .OrderBy(n => n.Order)
+ .LastOrDefault()?.OperatorKey ?? string.Empty;
+ _mainViewportService?.PushDetectionOverlay(execResult.LastStepOutputData, lastOperatorKey);
+ }
}
catch (Exception ex)
{
diff --git a/XplorePlane/Services/ImageProcessing/DetectionOverlayRenderer.cs b/XplorePlane/Services/ImageProcessing/DetectionOverlayRenderer.cs
new file mode 100644
index 0000000..c2c2513
--- /dev/null
+++ b/XplorePlane/Services/ImageProcessing/DetectionOverlayRenderer.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using XP.ImageProcessing.Processors;
+
+namespace XplorePlane.Services
+{
+ ///
+ /// 检测结果叠加层渲染器:根据算子输出数据构建 WPF Canvas 叠加层。
+ /// 用于在 PolygonRoiCanvas 上分层绘制检测结果(轮廓、标注、半透明填充)。
+ ///
+ public static class DetectionOverlayRenderer
+ {
+ ///
+ /// 根据算子输出数据构建叠加层 Canvas。
+ ///
+ public static Canvas BuildOverlay(IReadOnlyDictionary outputData, string operatorKey)
+ {
+ if (outputData == null) return null;
+
+ var canvas = new Canvas
+ {
+ IsHitTestVisible = false,
+ Background = Brushes.Transparent
+ };
+
+ if (string.Equals(operatorKey, "BgaVoidRate", StringComparison.OrdinalIgnoreCase))
+ RenderBgaOverlay(canvas, outputData);
+ else if (string.Equals(operatorKey, "VoidMeasurement", StringComparison.OrdinalIgnoreCase))
+ RenderVoidOverlay(canvas, outputData);
+
+ return canvas.Children.Count > 0 ? canvas : null;
+ }
+
+ private static void RenderBgaOverlay(Canvas canvas, IReadOnlyDictionary outputData)
+ {
+ if (!outputData.TryGetValue("BgaBalls", out var ballsObj)) return;
+ if (ballsObj is not List bgaBalls) return;
+ if (bgaBalls.Count == 0) return;
+
+ int ngCount = 0;
+ foreach (var bga in bgaBalls)
+ {
+ bool isFail = bga.Classification != "PASS";
+ if (isFail) ngCount++;
+
+ var contourBrush = isFail ? Brushes.Red : Brushes.Lime;
+
+ // 绘制焊球轮廓
+ if (bga.ContourPoints != null && bga.ContourPoints.Length > 2)
+ {
+ var polygon = new Polygon
+ {
+ Stroke = contourBrush,
+ StrokeThickness = 2,
+ Fill = Brushes.Transparent,
+ IsHitTestVisible = false
+ };
+ var points = new PointCollection();
+ foreach (var pt in bga.ContourPoints)
+ points.Add(new Point(pt.X, pt.Y));
+ polygon.Points = points;
+ canvas.Children.Add(polygon);
+ }
+
+ // 绘制气泡填充(半透明)
+ foreach (var v in bga.Voids)
+ {
+ if (v.ContourPoints != null && v.ContourPoints.Length > 2)
+ {
+ var voidPoly = new Polygon
+ {
+ Stroke = Brushes.Orange,
+ StrokeThickness = 1,
+ Fill = new SolidColorBrush(Color.FromArgb(100, 255, 200, 0)),
+ IsHitTestVisible = false
+ };
+ var voidPoints = new PointCollection();
+ foreach (var pt in v.ContourPoints)
+ voidPoints.Add(new Point(pt.X, pt.Y));
+ voidPoly.Points = voidPoints;
+ canvas.Children.Add(voidPoly);
+ }
+ }
+
+ // 编号标注
+ var label = new TextBlock
+ {
+ Text = $"#{bga.Index}",
+ FontSize = 12,
+ FontWeight = FontWeights.Bold,
+ Foreground = Brushes.Cyan,
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(label, bga.CenterX - 10);
+ Canvas.SetTop(label, bga.CenterY - 8);
+ canvas.Children.Add(label);
+ }
+
+ // 总览标注
+ int okCount = bgaBalls.Count - ngCount;
+ var summaryLabel = new TextBlock
+ {
+ Text = $"Total: {bgaBalls.Count} | OK: {okCount} | NG: {ngCount}",
+ FontSize = 14,
+ FontWeight = FontWeights.Bold,
+ Foreground = ngCount > 0 ? Brushes.Red : Brushes.Lime,
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(summaryLabel, 10);
+ Canvas.SetTop(summaryLabel, 10);
+ canvas.Children.Add(summaryLabel);
+ }
+
+ private static void RenderVoidOverlay(Canvas canvas, IReadOnlyDictionary outputData)
+ {
+ if (!outputData.TryGetValue("Voids", out var voidsObj)) return;
+ if (voidsObj is not List voids) return;
+ if (voids.Count == 0) return;
+
+ double voidRate = outputData.TryGetValue("VoidRate", out var vrObj) && vrObj is double vr ? vr : 0;
+ double voidLimit = outputData.TryGetValue("VoidLimit", out var vlObj) && vlObj is double vl ? vl : 25.0;
+ string classification = outputData.TryGetValue("Classification", out var clsObj) && clsObj is string cls ? cls : "N/A";
+
+ foreach (var v in voids)
+ {
+ // 绘制空隙轮廓(半透明填充)
+ if (v.ContourPoints != null && v.ContourPoints.Length > 2)
+ {
+ var voidPoly = new Polygon
+ {
+ Stroke = Brushes.Yellow,
+ StrokeThickness = 1,
+ Fill = new SolidColorBrush(Color.FromArgb(100, 255, 200, 0)),
+ IsHitTestVisible = false
+ };
+ var points = new PointCollection();
+ foreach (var pt in v.ContourPoints)
+ points.Add(new Point(pt.X, pt.Y));
+ voidPoly.Points = points;
+ canvas.Children.Add(voidPoly);
+ }
+
+ // 编号标注
+ var label = new TextBlock
+ {
+ Text = $"#{v.Index}",
+ FontSize = 10,
+ FontWeight = FontWeights.Bold,
+ Foreground = Brushes.Cyan,
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(label, v.CenterX - 8);
+ Canvas.SetTop(label, v.CenterY - 6);
+ canvas.Children.Add(label);
+ }
+
+ // 总览标注
+ var overallColor = classification == "PASS" ? Brushes.Lime : Brushes.Red;
+ var summaryLabel = new TextBlock
+ {
+ Text = $"Void: {voidRate:F1}% | Limit: {voidLimit:F0}% | {voids.Count} voids | {classification}",
+ FontSize = 14,
+ FontWeight = FontWeights.Bold,
+ Foreground = overallColor,
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(summaryLabel, 10);
+ Canvas.SetTop(summaryLabel, 10);
+ canvas.Children.Add(summaryLabel);
+ }
+ }
+}
diff --git a/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs b/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs
index a87f67c..46ec2c5 100644
--- a/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs
+++ b/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs
@@ -258,28 +258,15 @@ namespace XplorePlane.Services
var processedEmgu = processor.Process(emguImage);
progress?.Report(0.9);
- BitmapSource result;
-
- // 如果处理器输出了渲染后的彩色结果图像,优先使用它
- if (processor.OutputData.TryGetValue("RenderedResultImage", out var renderedObj)
- && renderedObj is Emgu.CV.Image renderedImage)
- {
- result = ImageConverter.ToBitmapSourceFromBgr(renderedImage);
- renderedImage.Dispose();
- }
- else
- {
- result = ImageConverter.ToBitmapSource(processedEmgu);
- }
-
+ var result = ImageConverter.ToBitmapSource(processedEmgu);
result.Freeze();
progress?.Report(1.0);
var snapshot = new Dictionary(processor.OutputData.Count);
foreach (var kv in processor.OutputData)
{
- // 不将大型图像对象序列化到快照中
- if (kv.Key == "RenderedResultImage") continue;
+ // 不将大型 Emgu 图像对象序列化到快照中
+ if (kv.Key == "RenderedResultImage" || kv.Key == "RoiMask") continue;
snapshot[kv.Key] = kv.Value;
}
diff --git a/XplorePlane/Services/MainViewport/IMainViewportService.cs b/XplorePlane/Services/MainViewport/IMainViewportService.cs
index f1b7e33..86c8fc1 100644
--- a/XplorePlane/Services/MainViewport/IMainViewportService.cs
+++ b/XplorePlane/Services/MainViewport/IMainViewportService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Windows.Media;
namespace XplorePlane.Services.MainViewport
@@ -34,5 +35,28 @@ namespace XplorePlane.Services.MainViewport
/// 与 不同,此方法在 CNC 运行期间不会被阻断。
///
void SetCncResultImage(ImageSource image, string label);
+
+ ///
+ /// 推送检测结果叠加层数据(轮廓、标注等),由 UI 分层绘制。
+ ///
+ event EventHandler DetectionOverlayUpdated;
+
+ ///
+ /// 由 CNC 执行引擎调用,将检测算子的输出数据推送给 UI 叠加层。
+ ///
+ void PushDetectionOverlay(IReadOnlyDictionary outputData, string operatorKey);
+ }
+
+ /// 检测结果叠加层事件参数
+ public class DetectionOverlayEventArgs : EventArgs
+ {
+ public IReadOnlyDictionary OutputData { get; }
+ public string OperatorKey { get; }
+
+ public DetectionOverlayEventArgs(IReadOnlyDictionary outputData, string operatorKey)
+ {
+ OutputData = outputData;
+ OperatorKey = operatorKey;
+ }
}
}
diff --git a/XplorePlane/Services/MainViewport/MainViewportService.cs b/XplorePlane/Services/MainViewport/MainViewportService.cs
index 385ee60..87e8d27 100644
--- a/XplorePlane/Services/MainViewport/MainViewportService.cs
+++ b/XplorePlane/Services/MainViewport/MainViewportService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Windows.Media;
@@ -208,6 +209,14 @@ namespace XplorePlane.Services.MainViewport
RaiseStateChanged();
}
+ public event EventHandler DetectionOverlayUpdated;
+
+ public void PushDetectionOverlay(IReadOnlyDictionary outputData, string operatorKey)
+ {
+ if (outputData == null) return;
+ DetectionOverlayUpdated?.Invoke(this, new DetectionOverlayEventArgs(outputData, operatorKey));
+ }
+
public void SetManualImage(ImageSource image, string filePath)
{
if (image == null)
diff --git a/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs b/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs
index 76d9825..96d1c4b 100644
--- a/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs
+++ b/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs
@@ -11,9 +11,11 @@ namespace XplorePlane.Services
/// 流水线输出图像(始终为灰度预览路径下的结果)。
/// 当且仅当最后一步为旋转模板匹配且需绘制匹配框时,为 OutputData 的副本,供 UI 透明叠加层使用;否则为 null。
+ /// 最后一步算子的 OutputData 快照,供检测结果叠加层使用。
public sealed record PipelineExecutionResult(
BitmapSource Image,
- IReadOnlyDictionary? TemplateMatchOverlayData);
+ IReadOnlyDictionary? TemplateMatchOverlayData,
+ IReadOnlyDictionary? LastStepOutputData = null);
public interface IPipelineExecutionService
{
diff --git a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs
index b28c2c8..9feafd6 100644
--- a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs
+++ b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs
@@ -41,6 +41,7 @@ namespace XplorePlane.Services
var total = enabledNodes.Count;
IReadOnlyDictionary? templateOverlayData = null;
+ IReadOnlyDictionary? lastStepOutputData = null;
for (var step = 0; step < total; step++)
{
@@ -82,6 +83,7 @@ namespace XplorePlane.Services
{
templateOverlayData = TemplateMatchOverlayRenderer.TrySnapshotOutputForOverlay(
node.OperatorKey, parameters, output);
+ lastStepOutputData = output;
}
}
catch (OperationCanceledException)
@@ -107,7 +109,7 @@ namespace XplorePlane.Services
if (!current.IsFrozen)
current.Freeze();
- return new PipelineExecutionResult(current, templateOverlayData);
+ return new PipelineExecutionResult(current, templateOverlayData, lastStepOutputData);
}
private static BitmapSource ScaleForPreview(BitmapSource source)
diff --git a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
index b2714a1..90ffec3 100644
--- a/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
+++ b/XplorePlane/Views/Main/ViewportPanelView.xaml.cs
@@ -9,6 +9,7 @@ using Microsoft.Win32;
using Prism.Ioc;
using XP.ImageProcessing.RoiControl.Controls;
using XplorePlane.Events;
+using XplorePlane.Services;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
@@ -126,6 +127,24 @@ namespace XplorePlane.Views
}
catch { }
+ // 订阅检测结果叠加层事件
+ try
+ {
+ var viewportService = ContainerLocator.Current?.Resolve();
+ if (viewportService != null)
+ {
+ viewportService.DetectionOverlayUpdated += (s, args) =>
+ {
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ var overlay = DetectionOverlayRenderer.BuildOverlay(args.OutputData, args.OperatorKey);
+ RoiCanvas.SetDetectionOverlayCanvas(overlay);
+ }));
+ };
+ }
+ }
+ catch { }
+
// 光标信息:从 RoiCanvas.CursorInfo 同步到 MainViewModel
var cursorInfoDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty(
PolygonRoiCanvas.CursorInfoProperty, typeof(PolygonRoiCanvas));