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));