优化高级模块CNC执行的可视化

CNC执行 → PipelineExecutionService(返回 LastStepOutputData)
                  → CncExecutionService(调用 PushDetectionOverlay)
                  → MainViewportService(触发 DetectionOverlayUpdated 事件)
                  → ViewportPanelView(订阅事件,调用 DetectionOverlayRenderer)
                  → PolygonRoiCanvas.SetDetectionOverlayCanvas(插入叠加层 Canvas)
This commit is contained in:
zhengxuan.zhang
2026-05-19 14:10:16 +08:00
parent eb6ee48a5e
commit 1546aec567
11 changed files with 279 additions and 135 deletions
@@ -216,9 +216,6 @@ public class BgaVoidRateProcessor : ImageProcessorBase
OutputData["Thickness"] = thickness; OutputData["Thickness"] = thickness;
OutputData["ResultText"] = $"Void: {overallVoidRate:F1}% | {classification} | BGA×{bgaResults.Count}"; OutputData["ResultText"] = $"Void: {overallVoidRate:F1}% | {classification} | BGA×{bgaResults.Count}";
// 渲染带标注的彩色结果图像
OutputData["RenderedResultImage"] = RenderAnnotatedResult(inputImage, bgaResults, voidLimit, thickness);
roiMask?.Dispose(); roiMask?.Dispose();
return inputImage.Clone(); return inputImage.Clone();
} }
@@ -375,67 +372,6 @@ public class BgaVoidRateProcessor : ImageProcessorBase
mask.Dispose(); mask.Dispose();
voidImg.Dispose(); voidImg.Dispose();
} }
/// <summary>
/// 渲染带标注的彩色结果图像(轮廓、编号、气泡填充、总览信息)
/// </summary>
private Image<Bgr, byte> RenderAnnotatedResult(Image<Gray, byte> grayImage, List<BgaBallInfo> bgaResults, double voidLimit, int thickness)
{
var colorImage = new Image<Bgr, byte>(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;
}
} }
/// <summary> /// <summary>
@@ -207,65 +207,12 @@ public class VoidMeasurementProcessor : ImageProcessorBase
OutputData["Voids"] = voids; OutputData["Voids"] = voids;
OutputData["ResultText"] = $"Void: {voidRate:F1}% | {classification} | {voids.Count} voids | ROI: {roiArea}px"; OutputData["ResultText"] = $"Void: {voidRate:F1}% | {classification} | {voids.Count} voids | ROI: {roiArea}px";
// 渲染带标注的彩色结果图像
OutputData["RenderedResultImage"] = RenderAnnotatedResult(inputImage, voids, voidRate, voidLimit, classification);
blurred.Dispose(); blurred.Dispose();
voidImg.Dispose(); voidImg.Dispose();
roiMask.Dispose(); roiMask.Dispose();
return inputImage.Clone(); return inputImage.Clone();
} }
/// <summary>
/// 渲染带标注的彩色结果图像(轮廓、编号、半透明填充、总览信息)
/// </summary>
private Image<Bgr, byte> RenderAnnotatedResult(Image<Gray, byte> grayImage, List<VoidRegionInfo> voids, double voidRate, double voidLimit, string classification)
{
var colorImage = new Image<Bgr, byte>(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;
}
} }
/// <summary> /// <summary>
@@ -671,6 +671,38 @@ namespace XP.ImageProcessing.RoiControl.Controls
if (element != null && mainCanvas.Children.Contains(element)) if (element != null && mainCanvas.Children.Contains(element))
mainCanvas.Children.Remove(element); mainCanvas.Children.Remove(element);
} }
// ── 检测结果叠加层 ──
private Canvas _detectionOverlay;
/// <summary>
/// 设置检测结果叠加层 Canvas(由外部构建好后传入)。
/// </summary>
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);
}
/// <summary>清除检测结果叠加层</summary>
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; public int MeasureCount => _ppGroups.Count + _ptlGroups.Count + _angleGroups.Count + _frGroups.Count + _bgaGroups.Count;
// ── 点击分发 ── // ── 点击分发 ──
@@ -718,6 +718,16 @@ namespace XplorePlane.Services.Cnc
nodeResult.Status = InspectionNodeStatus.Succeeded; nodeResult.Status = InspectionNodeStatus.Succeeded;
_mainViewportService?.SetCncResultImage(resultImage, $"CNC \u8282\u70b9\u7ed3\u679c\uff1a{inspectionNode.Name}"); _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) catch (Exception ex)
{ {
@@ -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
{
/// <summary>
/// 检测结果叠加层渲染器:根据算子输出数据构建 WPF Canvas 叠加层。
/// 用于在 PolygonRoiCanvas 上分层绘制检测结果(轮廓、标注、半透明填充)。
/// </summary>
public static class DetectionOverlayRenderer
{
/// <summary>
/// 根据算子输出数据构建叠加层 Canvas。
/// </summary>
public static Canvas BuildOverlay(IReadOnlyDictionary<string, object> 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<string, object> outputData)
{
if (!outputData.TryGetValue("BgaBalls", out var ballsObj)) return;
if (ballsObj is not List<BgaBallInfo> 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<string, object> outputData)
{
if (!outputData.TryGetValue("Voids", out var voidsObj)) return;
if (voidsObj is not List<VoidRegionInfo> 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);
}
}
}
@@ -258,28 +258,15 @@ namespace XplorePlane.Services
var processedEmgu = processor.Process(emguImage); var processedEmgu = processor.Process(emguImage);
progress?.Report(0.9); progress?.Report(0.9);
BitmapSource result; var result = ImageConverter.ToBitmapSource(processedEmgu);
// 如果处理器输出了渲染后的彩色结果图像,优先使用它
if (processor.OutputData.TryGetValue("RenderedResultImage", out var renderedObj)
&& renderedObj is Emgu.CV.Image<Emgu.CV.Structure.Bgr, byte> renderedImage)
{
result = ImageConverter.ToBitmapSourceFromBgr(renderedImage);
renderedImage.Dispose();
}
else
{
result = ImageConverter.ToBitmapSource(processedEmgu);
}
result.Freeze(); result.Freeze();
progress?.Report(1.0); progress?.Report(1.0);
var snapshot = new Dictionary<string, object>(processor.OutputData.Count); var snapshot = new Dictionary<string, object>(processor.OutputData.Count);
foreach (var kv in processor.OutputData) foreach (var kv in processor.OutputData)
{ {
// 不将大型图像对象序列化到快照中 // 不将大型 Emgu 图像对象序列化到快照中
if (kv.Key == "RenderedResultImage") continue; if (kv.Key == "RenderedResultImage" || kv.Key == "RoiMask") continue;
snapshot[kv.Key] = kv.Value; snapshot[kv.Key] = kv.Value;
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Windows.Media; using System.Windows.Media;
namespace XplorePlane.Services.MainViewport namespace XplorePlane.Services.MainViewport
@@ -34,5 +35,28 @@ namespace XplorePlane.Services.MainViewport
/// 与 <see cref="SetManualImage"/> 不同,此方法在 CNC 运行期间不会被阻断。 /// 与 <see cref="SetManualImage"/> 不同,此方法在 CNC 运行期间不会被阻断。
/// </summary> /// </summary>
void SetCncResultImage(ImageSource image, string label); void SetCncResultImage(ImageSource image, string label);
/// <summary>
/// 推送检测结果叠加层数据(轮廓、标注等),由 UI 分层绘制。
/// </summary>
event EventHandler<DetectionOverlayEventArgs> DetectionOverlayUpdated;
/// <summary>
/// 由 CNC 执行引擎调用,将检测算子的输出数据推送给 UI 叠加层。
/// </summary>
void PushDetectionOverlay(IReadOnlyDictionary<string, object> outputData, string operatorKey);
}
/// <summary>检测结果叠加层事件参数</summary>
public class DetectionOverlayEventArgs : EventArgs
{
public IReadOnlyDictionary<string, object> OutputData { get; }
public string OperatorKey { get; }
public DetectionOverlayEventArgs(IReadOnlyDictionary<string, object> outputData, string operatorKey)
{
OutputData = outputData;
OperatorKey = operatorKey;
}
} }
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Configuration; using System.Configuration;
using System.IO; using System.IO;
using System.Windows.Media; using System.Windows.Media;
@@ -208,6 +209,14 @@ namespace XplorePlane.Services.MainViewport
RaiseStateChanged(); RaiseStateChanged();
} }
public event EventHandler<DetectionOverlayEventArgs> DetectionOverlayUpdated;
public void PushDetectionOverlay(IReadOnlyDictionary<string, object> outputData, string operatorKey)
{
if (outputData == null) return;
DetectionOverlayUpdated?.Invoke(this, new DetectionOverlayEventArgs(outputData, operatorKey));
}
public void SetManualImage(ImageSource image, string filePath) public void SetManualImage(ImageSource image, string filePath)
{ {
if (image == null) if (image == null)
@@ -11,9 +11,11 @@ namespace XplorePlane.Services
/// <param name="Image">流水线输出图像(始终为灰度预览路径下的结果)。</param> /// <param name="Image">流水线输出图像(始终为灰度预览路径下的结果)。</param>
/// <param name="TemplateMatchOverlayData">当且仅当最后一步为旋转模板匹配且需绘制匹配框时,为 OutputData 的副本,供 UI 透明叠加层使用;否则为 null。</param> /// <param name="TemplateMatchOverlayData">当且仅当最后一步为旋转模板匹配且需绘制匹配框时,为 OutputData 的副本,供 UI 透明叠加层使用;否则为 null。</param>
/// <param name="LastStepOutputData">最后一步算子的 OutputData 快照,供检测结果叠加层使用。</param>
public sealed record PipelineExecutionResult( public sealed record PipelineExecutionResult(
BitmapSource Image, BitmapSource Image,
IReadOnlyDictionary<string, object>? TemplateMatchOverlayData); IReadOnlyDictionary<string, object>? TemplateMatchOverlayData,
IReadOnlyDictionary<string, object>? LastStepOutputData = null);
public interface IPipelineExecutionService public interface IPipelineExecutionService
{ {
@@ -41,6 +41,7 @@ namespace XplorePlane.Services
var total = enabledNodes.Count; var total = enabledNodes.Count;
IReadOnlyDictionary<string, object>? templateOverlayData = null; IReadOnlyDictionary<string, object>? templateOverlayData = null;
IReadOnlyDictionary<string, object>? lastStepOutputData = null;
for (var step = 0; step < total; step++) for (var step = 0; step < total; step++)
{ {
@@ -82,6 +83,7 @@ namespace XplorePlane.Services
{ {
templateOverlayData = TemplateMatchOverlayRenderer.TrySnapshotOutputForOverlay( templateOverlayData = TemplateMatchOverlayRenderer.TrySnapshotOutputForOverlay(
node.OperatorKey, parameters, output); node.OperatorKey, parameters, output);
lastStepOutputData = output;
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -107,7 +109,7 @@ namespace XplorePlane.Services
if (!current.IsFrozen) if (!current.IsFrozen)
current.Freeze(); current.Freeze();
return new PipelineExecutionResult(current, templateOverlayData); return new PipelineExecutionResult(current, templateOverlayData, lastStepOutputData);
} }
private static BitmapSource ScaleForPreview(BitmapSource source) private static BitmapSource ScaleForPreview(BitmapSource source)
@@ -9,6 +9,7 @@ using Microsoft.Win32;
using Prism.Ioc; using Prism.Ioc;
using XP.ImageProcessing.RoiControl.Controls; using XP.ImageProcessing.RoiControl.Controls;
using XplorePlane.Events; using XplorePlane.Events;
using XplorePlane.Services;
using XplorePlane.ViewModels; using XplorePlane.ViewModels;
namespace XplorePlane.Views namespace XplorePlane.Views
@@ -126,6 +127,24 @@ namespace XplorePlane.Views
} }
catch { } catch { }
// 订阅检测结果叠加层事件
try
{
var viewportService = ContainerLocator.Current?.Resolve<Services.MainViewport.IMainViewportService>();
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 // 光标信息:从 RoiCanvas.CursorInfo 同步到 MainViewModel
var cursorInfoDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty( var cursorInfoDesc = System.ComponentModel.DependencyPropertyDescriptor.FromProperty(
PolygonRoiCanvas.CursorInfoProperty, typeof(PolygonRoiCanvas)); PolygonRoiCanvas.CursorInfoProperty, typeof(PolygonRoiCanvas));