diff --git a/XP.Common/Resources/Resources.Designer.cs b/XP.Common/Resources/Resources.Designer.cs index a99fa51..4a7faf8 100644 --- a/XP.Common/Resources/Resources.Designer.cs +++ b/XP.Common/Resources/Resources.Designer.cs @@ -780,6 +780,159 @@ namespace XP.Common.Resources { } } + /// + /// 查找类似 模板匹配 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_Name { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_Name", resourceCulture); + } + } + + /// + /// 查找类似 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_Description { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_Description", resourceCulture); + } + } + + /// + /// 查找类似 模板文件路径 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_TemplatePath { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_TemplatePath", resourceCulture); + } + } + + /// + /// 查找类似 磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度) 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_TemplatePath_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_TemplatePath_Desc", resourceCulture); + } + } + + /// + /// 查找类似 匹配方法 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_MatchMethod { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_MatchMethod", resourceCulture); + } + } + + /// + /// 查找类似 OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_MatchMethod_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_MatchMethod_Desc", resourceCulture); + } + } + + /// + /// 查找类似 匹配阈值 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_MatchThreshold { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_MatchThreshold", resourceCulture); + } + } + + /// + /// 查找类似 相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3) 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_MatchThreshold_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_MatchThreshold_Desc", resourceCulture); + } + } + + /// + /// 查找类似 绘制匹配矩形 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_DrawMatch { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_DrawMatch", resourceCulture); + } + } + + /// + /// 查找类似 匹配通过阈值时在输出图上用白框标出模板区域 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_DrawMatch_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_DrawMatch_Desc", resourceCulture); + } + } + + /// + /// 查找类似 矩形线宽 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_RectThickness { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_RectThickness", resourceCulture); + } + } + + /// + /// 查找类似 匹配框线宽(像素) 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_RectThickness_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_RectThickness_Desc", resourceCulture); + } + } + + /// + /// 查找类似 Search region X 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_SearchRegionX { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionX", resourceCulture); + } + } + + /// + /// 查找类似 Search region Y 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_SearchRegionY { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionY", resourceCulture); + } + } + + /// + /// 查找类似 Search region width 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_SearchRegionWidth { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionWidth", resourceCulture); + } + } + + /// + /// 查找类似 Search region height 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_SearchRegionHeight { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionHeight", resourceCulture); + } + } + + /// + /// 查找类似 Rectangular search window in source pixels. 的本地化字符串。 + /// + public static string TemplateMatchingProcessor_SearchRegion_Desc { + get { + return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegion_Desc", resourceCulture); + } + } + /// /// 查找类似 自动对比度 的本地化字符串。 /// diff --git a/XP.Common/Resources/Resources.en-US.resx b/XP.Common/Resources/Resources.en-US.resx index 96d965f..5264c76 100644 --- a/XP.Common/Resources/Resources.en-US.resx +++ b/XP.Common/Resources/Resources.en-US.resx @@ -489,6 +489,57 @@ Thickness of contour lines + + Template Matching + + + Find the best match for a template in the grayscale image; optionally draw the match rectangle + + + Template file path + + + Path to template image (bmp/png/jpg, etc.); color images are converted to grayscale + + + Match method + + + OpenCV template method; higher is better for CcoeffNormed/CcorrNormed; lower is better for SqdiffNormed (0 = identical) + + + Match threshold + + + Correlation methods: match if score ≥ threshold. Sqdiff/SqdiffNormed: match if score ≤ threshold (try 0.1–0.3 for Normed) + + + Draw match rectangle + + + When match passes threshold, draw a white rectangle on the output + + + Rectangle thickness + + + Line width of the match box in pixels + + + Search region X + + + Search region Y + + + Search region width + + + Search region height + + + Rectangular search window in source pixels. Width or height 0 means full image. Written by the template matching tool. + Division Operation diff --git a/XP.Common/Resources/Resources.resx b/XP.Common/Resources/Resources.resx index 390fd24..6ad3846 100644 --- a/XP.Common/Resources/Resources.resx +++ b/XP.Common/Resources/Resources.resx @@ -489,6 +489,57 @@ 绘制轮廓的线条粗细 + + 模板匹配 + + + 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 + + + 模板文件路径 + + + 磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度) + + + 匹配方法 + + + OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) + + + 匹配阈值 + + + 相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3) + + + 绘制匹配矩形 + + + 匹配通过阈值时在输出图上用白框标出模板区域 + + + 矩形线宽 + + + 匹配框线宽(像素) + + + Search region X + + + Search region Y + + + Search region width + + + Search region height + + + Rectangular search window in source pixels. Width or height 0 means full image. Written by the template matching tool. + 除法运算 diff --git a/XP.Common/Resources/Resources.zh-CN.resx b/XP.Common/Resources/Resources.zh-CN.resx index cc06405..87263c6 100644 --- a/XP.Common/Resources/Resources.zh-CN.resx +++ b/XP.Common/Resources/Resources.zh-CN.resx @@ -489,6 +489,57 @@ 绘制轮廓的线条粗细 + + 模板匹配 + + + 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 + + + 模板文件路径 + + + 磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度) + + + 匹配方法 + + + OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) + + + 匹配阈值 + + + 相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3) + + + 绘制匹配矩形 + + + 匹配通过阈值时在输出图上用白框标出模板区域 + + + 矩形线宽 + + + 匹配框线宽(像素) + + + 搜索区域 X + + + 搜索区域 Y + + + 搜索区域宽度 + + + 搜索区域高度 + + + 在源图上的矩形搜索范围(像素)。宽或高为 0 时表示整幅图。由模板匹配工具写入。 + 除法运算 diff --git a/XP.Common/Resources/Resources.zh-TW.resx b/XP.Common/Resources/Resources.zh-TW.resx index d2db67d..7eb25ae 100644 --- a/XP.Common/Resources/Resources.zh-TW.resx +++ b/XP.Common/Resources/Resources.zh-TW.resx @@ -489,6 +489,57 @@ 绘制轮廓的线条粗细 + + 模板匹配 + + + 在整幅灰階圖中搜尋模板影像的最佳位置,可選繪製匹配框 + + + 模板檔案路徑 + + + 磁碟上的模板影像路徑(支援 bmp/png/jpg 等,彩色將轉為灰階) + + + 匹配方法 + + + OpenCV 模板匹配類型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) + + + 匹配閾值 + + + 相關類方法:得分需≥該值判為匹配;Sqdiff/SqdiffNormed:得分需≤該值(建議 Normed 時 0.1~0.3) + + + 繪製匹配矩形 + + + 匹配通過閾值時在輸出圖上以白框標出模板區域 + + + 矩形線寬 + + + 匹配框線寬(像素) + + + 搜尋區域 X + + + 搜尋區域 Y + + + 搜尋區域寬度 + + + 搜尋區域高度 + + + 在源圖上的矩形搜尋範圍(像素)。寬或高為 0 表示整幅圖。由模板匹配工具寫入。 + 除法运算 diff --git a/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs b/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs new file mode 100644 index 0000000..b6db3df --- /dev/null +++ b/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs @@ -0,0 +1,282 @@ +// ============================================================================ +// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. +// 文件名: TemplateMatchingProcessor.cs +// 描述: 灰度模板匹配算子,在整幅图像中搜索与模板最相似的位置 +// 功能: +// - 从磁盘加载模板(灰度或彩色图自动转灰度) +// - OpenCV MatchTemplate + MinMaxLoc 求最佳匹配 +// - 可选在输出图上绘制匹配矩形 +// - 将匹配结果写入 OutputData(坐标、得分、是否通过阈值) +// 作者: 李伟 wei.lw.li@hexagon.com +// ============================================================================ + +using System.Drawing; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using XP.ImageProcessing.Core; +using Serilog; + +namespace XP.ImageProcessing.Processors; + +/// +/// 模板匹配算子(定位识别) +/// +public class TemplateMatchingProcessor : ImageProcessorBase +{ + private static readonly ILogger _logger = Log.ForContext(); + + private static readonly string[] MatchMethodOptions = + { + "CcoeffNormed", + "SqdiffNormed", + "CcorrNormed", + "Ccoeff", + "Ccorr", + "Sqdiff" + }; + + public TemplateMatchingProcessor() + { + Name = LocalizationHelper.GetString("TemplateMatchingProcessor_Name"); + Description = LocalizationHelper.GetString("TemplateMatchingProcessor_Description"); + } + + protected override void InitializeParameters() + { + Parameters.Add("TemplatePath", new ProcessorParameter( + "TemplatePath", + LocalizationHelper.GetString("TemplateMatchingProcessor_TemplatePath"), + typeof(string), + string.Empty, + null, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_TemplatePath_Desc"))); + + Parameters.Add("MatchMethod", new ProcessorParameter( + "MatchMethod", + LocalizationHelper.GetString("TemplateMatchingProcessor_MatchMethod"), + typeof(string), + "CcoeffNormed", + null, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_MatchMethod_Desc"), + MatchMethodOptions)); + + Parameters.Add("MatchThreshold", new ProcessorParameter( + "MatchThreshold", + LocalizationHelper.GetString("TemplateMatchingProcessor_MatchThreshold"), + typeof(double), + 0.75, + 0.0, + 1.0, + LocalizationHelper.GetString("TemplateMatchingProcessor_MatchThreshold_Desc"))); + + Parameters.Add("DrawRectangle", new ProcessorParameter( + "DrawRectangle", + LocalizationHelper.GetString("TemplateMatchingProcessor_DrawMatch"), + typeof(bool), + true, + null, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_DrawMatch_Desc"))); + + Parameters.Add("RectangleThickness", new ProcessorParameter( + "RectangleThickness", + LocalizationHelper.GetString("TemplateMatchingProcessor_RectThickness"), + typeof(int), + 2, + 1, + 8, + LocalizationHelper.GetString("TemplateMatchingProcessor_RectThickness_Desc"))); + + Parameters.Add("SearchRegionX", new ProcessorParameter( + "SearchRegionX", + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionX"), + typeof(int), + 0, + 0, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); + + Parameters.Add("SearchRegionY", new ProcessorParameter( + "SearchRegionY", + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionY"), + typeof(int), + 0, + 0, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); + + Parameters.Add("SearchRegionWidth", new ProcessorParameter( + "SearchRegionWidth", + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionWidth"), + typeof(int), + 0, + 0, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); + + Parameters.Add("SearchRegionHeight", new ProcessorParameter( + "SearchRegionHeight", + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionHeight"), + typeof(int), + 0, + 0, + null, + LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); + + _logger.Debug("InitializeParameters"); + } + + public override Image Process(Image inputImage) + { + var path = (GetParameter("TemplatePath") ?? string.Empty).Trim(); + var methodName = GetParameter("MatchMethod") ?? "CcoeffNormed"; + var threshold = GetParameter("MatchThreshold"); + var draw = GetParameter("DrawRectangle"); + var thickness = GetParameter("RectangleThickness"); + var searchRx = GetParameter("SearchRegionX"); + var searchRy = GetParameter("SearchRegionY"); + var searchRw = GetParameter("SearchRegionWidth"); + var searchRh = GetParameter("SearchRegionHeight"); + + OutputData.Clear(); + + var output = inputImage.Clone(); + + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + _logger.Warning("TemplateMatching: invalid or missing template file: {Path}", path); + OutputData["Matched"] = false; + OutputData["Message"] = "Template file not found"; + return output; + } + + using var template = LoadTemplate(path); + if (template == null) + { + OutputData["Matched"] = false; + OutputData["Message"] = "Template load failed"; + return output; + } + + var searchRoi = ResolveSearchRoi(inputImage.Width, inputImage.Height, searchRx, searchRy, searchRw, searchRh); + var offsetX = searchRoi.X; + var offsetY = searchRoi.Y; + + Image? roiImage = null; + try + { + inputImage.ROI = searchRoi; + roiImage = inputImage.Copy(); + inputImage.ROI = Rectangle.Empty; + + if (template.Width > roiImage.Width || template.Height > roiImage.Height) + { + _logger.Warning("TemplateMatching: template larger than search region ({Tw}x{Th} vs {Iw}x{Ih})", + template.Width, template.Height, roiImage.Width, roiImage.Height); + OutputData["Matched"] = false; + OutputData["Message"] = "Template larger than search region"; + return output; + } + + var method = ParseMethod(methodName); + using var resultMat = new Mat(); + CvInvoke.MatchTemplate(roiImage, template, resultMat, method); + double minVal = 0, maxVal = 0; + Point minLoc = default, maxLoc = default; + CvInvoke.MinMaxLoc(resultMat, ref minVal, ref maxVal, ref minLoc, ref maxLoc); + + var useMin = method == TemplateMatchingType.Sqdiff || method == TemplateMatchingType.SqdiffNormed; + var loc = useMin ? minLoc : maxLoc; + var score = useMin ? minVal : maxVal; + var matched = IsMatchAcceptable(method, minVal, maxVal, threshold); + + var globalLoc = new Point(loc.X + offsetX, loc.Y + offsetY); + + OutputData["Matched"] = matched; + OutputData["MatchScore"] = score; + OutputData["MatchX"] = globalLoc.X; + OutputData["MatchY"] = globalLoc.Y; + OutputData["TemplateWidth"] = template.Width; + OutputData["TemplateHeight"] = template.Height; + OutputData["MatchMethod"] = methodName; + + if (matched && draw) + { + var rect = new Rectangle(globalLoc.X, globalLoc.Y, template.Width, template.Height); + CvInvoke.Rectangle(output, rect, new MCvScalar(255), thickness); + } + + _logger.Debug("TemplateMatching: Matched={Matched}, Score={Score}, Origin=({X},{Y}), SearchRoi=({Rx},{Ry},{Rw},{Rh})", + matched, score, globalLoc.X, globalLoc.Y, searchRoi.X, searchRoi.Y, searchRoi.Width, searchRoi.Height); + + return output; + } + finally + { + roiImage?.Dispose(); + } + } + + private static Rectangle ResolveSearchRoi(int imgW, int imgH, int rx, int ry, int rw, int rh) + { + if (rw <= 0 || rh <= 0) + return new Rectangle(0, 0, imgW, imgH); + + rx = Math.Clamp(rx, 0, Math.Max(0, imgW - 1)); + ry = Math.Clamp(ry, 0, Math.Max(0, imgH - 1)); + rw = Math.Clamp(rw, 1, Math.Max(1, imgW - rx)); + rh = Math.Clamp(rh, 1, Math.Max(1, imgH - ry)); + return new Rectangle(rx, ry, rw, rh); + } + + private static bool IsMatchAcceptable(TemplateMatchingType method, double minVal, double maxVal, double threshold) + { + return method switch + { + TemplateMatchingType.SqdiffNormed => minVal <= threshold, + TemplateMatchingType.Sqdiff => minVal <= threshold, + TemplateMatchingType.CcorrNormed or TemplateMatchingType.CcoeffNormed => maxVal >= threshold, + TemplateMatchingType.Ccorr or TemplateMatchingType.Ccoeff => maxVal >= threshold, + _ => maxVal >= threshold + }; + } + + private static TemplateMatchingType ParseMethod(string? name) + { + return name?.Trim() switch + { + "Sqdiff" => TemplateMatchingType.Sqdiff, + "SqdiffNormed" => TemplateMatchingType.SqdiffNormed, + "Ccorr" => TemplateMatchingType.Ccorr, + "CcorrNormed" => TemplateMatchingType.CcorrNormed, + "Ccoeff" => TemplateMatchingType.Ccoeff, + _ => TemplateMatchingType.CcoeffNormed + }; + } + + private static Image? LoadTemplate(string path) + { + try + { + using var raw = CvInvoke.Imread(path, ImreadModes.AnyDepth | ImreadModes.AnyColor); + if (raw.IsEmpty) + return null; + + var templ = new Image(raw.Width, raw.Height); + if (raw.NumberOfChannels == 1) + raw.CopyTo(templ.Mat); + else + CvInvoke.CvtColor(raw, templ, ColorConversion.Bgr2Gray); + + return templ; + } + catch (Exception ex) + { + _logger.Error(ex, "TemplateMatching: failed to load template {Path}", path); + return null; + } + } +} diff --git a/XplorePlane/Services/ImageProcessing/IImageProcessingService.cs b/XplorePlane/Services/ImageProcessing/IImageProcessingService.cs index e9160b6..d93f773 100644 --- a/XplorePlane/Services/ImageProcessing/IImageProcessingService.cs +++ b/XplorePlane/Services/ImageProcessing/IImageProcessingService.cs @@ -25,5 +25,15 @@ namespace XplorePlane.Services IDictionary parameters, IProgress progress = null, CancellationToken cancellationToken = default); + + /// + /// 执行单算子并返回输出图像与算子 快照(浅拷贝)。 + /// + Task<(BitmapSource image, IReadOnlyDictionary outputData)> ProcessImageWithOutputAsync( + BitmapSource source, + string processorName, + IDictionary parameters, + IProgress progress = null, + CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs b/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs index 7025567..177e30f 100644 --- a/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs +++ b/XplorePlane/Services/ImageProcessing/ImageProcessingService.cs @@ -220,6 +220,18 @@ namespace XplorePlane.Services IDictionary parameters, IProgress progress = null, CancellationToken cancellationToken = default) + { + var (image, _) = await ProcessImageWithOutputAsync(source, processorName, parameters, progress, cancellationToken) + .ConfigureAwait(false); + return image; + } + + public async Task<(BitmapSource image, IReadOnlyDictionary outputData)> ProcessImageWithOutputAsync( + BitmapSource source, + string processorName, + IDictionary parameters, + IProgress progress = null, + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -250,7 +262,11 @@ namespace XplorePlane.Services result.Freeze(); progress?.Report(1.0); - return result; + var snapshot = new Dictionary(processor.OutputData.Count); + foreach (var kv in processor.OutputData) + snapshot[kv.Key] = kv.Value; + + return (result, snapshot); } catch (OperationCanceledException) { @@ -265,7 +281,7 @@ namespace XplorePlane.Services _logger.Error(ex, "Image processing failed for processor: {ProcessorName}", processorName); throw new ImageProcessingException($"Image processing failed: {ex.Message}", ex); } - }, cancellationToken); + }, cancellationToken).ConfigureAwait(false); } public void Dispose() diff --git a/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs b/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs index 69b40c3..966f97b 100644 --- a/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs +++ b/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs @@ -12,7 +12,8 @@ namespace XplorePlane.Services ("数学运算", "➗", 3), ("形态学处理", "⬚", 4), ("边缘检测", "📐", 5), - ("检测分析", "🔎", 6), + ("定位识别", "📌", 6), + ("检测分析", "🔎", 7), ("其他", "⚙", 99), }; @@ -46,6 +47,9 @@ namespace XplorePlane.Services if (ContainsAny(operatorKey, "Edge")) return "边缘检测"; + if (ContainsAny(operatorKey, "TemplateMatching")) + return "定位识别"; + if (ContainsAny(operatorKey, "Measurement", "Detection", "Contour", "FillRate", "Void", "Line", "PointToLine", "Ellipse", "Bga")) return "检测分析"; @@ -129,6 +133,8 @@ namespace XplorePlane.Services return "⬚"; if (ContainsAny(operatorKey, "Sobel", "Kirsch", "HorizontalEdge")) return "📐"; + if (ContainsAny(operatorKey, "TemplateMatching")) + return "🎯"; if (ContainsAny(operatorKey, "Contour")) return "✏"; if (ContainsAny(operatorKey, "Measurement")) diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs index be00d31..2810d1a 100644 --- a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -17,6 +17,7 @@ using XplorePlane.Events; using XplorePlane.Models; using XplorePlane.Services; using XplorePlane.Services.MainViewport; +using XplorePlane.Views; using XP.Common.Logging.Interfaces; using Prism.Events; @@ -74,6 +75,7 @@ namespace XplorePlane.ViewModels.Cnc SavePipelineCommand = new DelegateCommand(SavePipelineToModule); SaveAsPipelineCommand = new DelegateCommand(async () => await SaveAsPipelineAsync()); LoadPipelineCommand = new DelegateCommand(async () => await LoadPipelineAsync()); + OpenTemplateMatchingToolCommand = new DelegateCommand(OpenTemplateMatchingTool, CanOpenTemplateMatchingTool); _editorViewModel.PropertyChanged += OnEditorPropertyChanged; RefreshFromSelection(); @@ -84,9 +86,22 @@ namespace XplorePlane.ViewModels.Cnc public PipelineNodeViewModel SelectedNode { get => _selectedNode; - set => SetProperty(ref _selectedNode, value); + set + { + if (!SetProperty(ref _selectedNode, value)) + return; + + RaisePropertyChanged(nameof(IsTemplateMatchingNodeSelected)); + (OpenTemplateMatchingToolCommand as DelegateCommand)?.RaiseCanExecuteChanged(); + } } + public bool IsTemplateMatchingNodeSelected => + SelectedNode != null && + string.Equals(SelectedNode.OperatorKey, "TemplateMatching", StringComparison.Ordinal); + + public ICommand OpenTemplateMatchingToolCommand { get; } + public string StatusMessage { get => _statusMessage; @@ -141,6 +156,45 @@ namespace XplorePlane.ViewModels.Cnc public ICommand LoadPipelineCommand { get; } + private static string? GetPipelineRecipeDirectory(string? currentFilePath) + { + if (string.IsNullOrWhiteSpace(currentFilePath)) + return null; + + try + { + var full = Path.GetFullPath(currentFilePath); + return Path.GetDirectoryName(full); + } + catch + { + return null; + } + } + + private bool CanOpenTemplateMatchingTool() => IsTemplateMatchingNodeSelected; + + private void OpenTemplateMatchingTool() + { + if (!CanOpenTemplateMatchingTool() || SelectedNode == null) + return; + + try + { + var recipeDir = GetPipelineRecipeDirectory(_currentFilePath); + var source = _mainViewportService?.CurrentDisplayImage as BitmapSource + ?? _mainViewportService?.LatestManualImage as BitmapSource; + var win = new TemplateMatchingToolWindow(SelectedNode, source, _imageProcessingService, _logger, recipeDir); + win.Owner = Application.Current?.MainWindow; + win.ShowDialog(); + } + catch (Exception ex) + { + _logger.Warn("打开模板匹配工具窗失败: {Message}", ex.Message); + StatusMessage = "无法打开模板匹配工具:" + ex.Message; + } + } + private void OnEditorPropertyChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(CncEditorViewModel.SelectedNode)) @@ -585,6 +639,7 @@ namespace XplorePlane.ViewModels.Cnc private void RaiseCommandCanExecuteChanged() { (AddOperatorCommand as DelegateCommand)?.RaiseCanExecuteChanged(); + (OpenTemplateMatchingToolCommand as DelegateCommand)?.RaiseCanExecuteChanged(); } private static object ConvertSavedValue(object savedValue, Type targetType) diff --git a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs index d29a005..26bd92e 100644 --- a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs @@ -38,5 +38,9 @@ namespace XplorePlane.ViewModels ICommand SaveAsPipelineCommand { get; } ICommand LoadPipelineCommand { get; } + + bool IsTemplateMatchingNodeSelected { get; } + + ICommand OpenTemplateMatchingToolCommand { get; } } } diff --git a/XplorePlane/ViewModels/ImageProcessing/OperatorToolboxViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/OperatorToolboxViewModel.cs index b704c3f..62e35c3 100644 --- a/XplorePlane/ViewModels/ImageProcessing/OperatorToolboxViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/OperatorToolboxViewModel.cs @@ -121,7 +121,8 @@ namespace XplorePlane.ViewModels "数学运算" => 3, "形态学处理" => 4, "边缘检测" => 5, - "检测分析" => 6, + "定位识别" => 6, + "检测分析" => 7, _ => 99 }; } diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index 2494bfd..613a83c 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Windows; using System.Windows.Input; using System.Windows.Media.Imaging; using XP.Common.Logging.Interfaces; @@ -15,6 +16,7 @@ using XplorePlane.Events; using XplorePlane.Models; using XplorePlane.Services; using XplorePlane.Services.Storage; +using XplorePlane.Views; namespace XplorePlane.ViewModels { @@ -46,6 +48,8 @@ namespace XplorePlane.ViewModels private CancellationTokenSource _executionCts; private CancellationTokenSource _debounceCts; + private readonly DelegateCommand _openTemplateMatchingToolCommand; + public PipelineEditorViewModel( IImageProcessingService imageProcessingService, IPipelineExecutionService executionService, @@ -80,6 +84,7 @@ namespace XplorePlane.ViewModels LoadImageCommand = new DelegateCommand(LoadImage); MoveNodeUpCommand = new DelegateCommand(MoveNodeUp); MoveNodeDownCommand = new DelegateCommand(MoveNodeDown); + _openTemplateMatchingToolCommand = new DelegateCommand(OpenTemplateMatchingTool, CanOpenTemplateMatchingTool); _eventAggregator.GetEvent() .Subscribe(OnManualImageLoaded); @@ -95,11 +100,20 @@ namespace XplorePlane.ViewModels get => _selectedNode; set { - if (SetProperty(ref _selectedNode, value) && value != null) - LoadNodeParameters(value); + if (SetProperty(ref _selectedNode, value)) + { + if (value != null) + LoadNodeParameters(value); + RaisePropertyChanged(nameof(IsTemplateMatchingNodeSelected)); + _openTemplateMatchingToolCommand.RaiseCanExecuteChanged(); + } } } + public bool IsTemplateMatchingNodeSelected => + SelectedNode != null && + string.Equals(SelectedNode.OperatorKey, "TemplateMatching", StringComparison.Ordinal); + public BitmapSource SourceImage { get => _sourceImage; @@ -202,6 +216,7 @@ namespace XplorePlane.ViewModels public DelegateCommand MoveNodeUpCommand { get; } public DelegateCommand MoveNodeDownCommand { get; } + public ICommand OpenTemplateMatchingToolCommand => _openTemplateMatchingToolCommand; ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand; ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand; @@ -413,6 +428,43 @@ namespace XplorePlane.ViewModels } } + private static string? GetPipelineRecipeDirectory(string? currentFilePath) + { + if (string.IsNullOrWhiteSpace(currentFilePath)) + return null; + + try + { + var full = Path.GetFullPath(currentFilePath); + return Path.GetDirectoryName(full); + } + catch + { + return null; + } + } + + private bool CanOpenTemplateMatchingTool() => IsTemplateMatchingNodeSelected; + + private void OpenTemplateMatchingTool() + { + if (!CanOpenTemplateMatchingTool() || SelectedNode == null) + return; + + try + { + var recipeDir = GetPipelineRecipeDirectory(_currentFilePath); + var win = new TemplateMatchingToolWindow(SelectedNode, SourceImage, _imageProcessingService, _logger, recipeDir); + win.Owner = Application.Current?.MainWindow; + win.ShowDialog(); + } + catch (Exception ex) + { + _logger.Warn("打开模板匹配工具窗失败: {Message}", ex.Message); + SetInfoStatus("无法打开模板匹配工具:" + ex.Message); + } + } + private async Task ExecutePipelineAsync() { if (SourceImage == null || IsExecuting) diff --git a/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs new file mode 100644 index 0000000..0c460b6 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs @@ -0,0 +1,538 @@ +using Microsoft.Win32; +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using XP.Common.Logging.Interfaces; +using XplorePlane.Services; + +namespace XplorePlane.ViewModels +{ + /// + /// 模板匹配算子独立工具窗:VisionMaster 风格双 ROI(模板 / 搜索)、学习模板、试跑。 + /// + public class TemplateMatchingToolViewModel : BindableBase + { + private const string OperatorKey = "TemplateMatching"; + + private readonly IImageProcessingService _imageProcessingService; + private readonly ILoggerService _logger; + private readonly string? _recipeDirectory; + private readonly ProcessorParameterVM? _searchRegionX; + private readonly ProcessorParameterVM? _searchRegionY; + private readonly ProcessorParameterVM? _searchRegionWidth; + private readonly ProcessorParameterVM? _searchRegionHeight; + + private bool _isBusy; + private string _runResultText = string.Empty; + private BitmapSource _previewImage; + private bool _hasPreview; + private string _cropXText = "0"; + private string _cropYText = "0"; + private string _cropWidthText = "128"; + private string _cropHeightText = "128"; + private int _roiOverlayRevision; + private bool _editSearchRoi; + + public TemplateMatchingToolViewModel( + PipelineNodeViewModel node, + BitmapSource? sourceImage, + IImageProcessingService imageProcessingService, + ILoggerService logger, + string? recipeDirectory = null) + { + Node = node ?? throw new ArgumentNullException(nameof(node)); + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + SourceImage = sourceImage; + _recipeDirectory = NormalizeRecipeDirectory(recipeDirectory); + + TemplatePathParameter = node.Parameters.FirstOrDefault(p => + string.Equals(p.Name, "TemplatePath", StringComparison.Ordinal)); + + _searchRegionX = node.Parameters.FirstOrDefault(p => string.Equals(p.Name, "SearchRegionX", StringComparison.Ordinal)); + _searchRegionY = node.Parameters.FirstOrDefault(p => string.Equals(p.Name, "SearchRegionY", StringComparison.Ordinal)); + _searchRegionWidth = node.Parameters.FirstOrDefault(p => string.Equals(p.Name, "SearchRegionWidth", StringComparison.Ordinal)); + _searchRegionHeight = node.Parameters.FirstOrDefault(p => string.Equals(p.Name, "SearchRegionHeight", StringComparison.Ordinal)); + + OtherParameters = new ObservableCollection( + node.Parameters.Where(p => + !string.Equals(p.Name, "TemplatePath", StringComparison.Ordinal) && + !IsHiddenSearchRoiParameter(p.Name))); + + BrowseTemplateCommand = new DelegateCommand(OnBrowseTemplate, () => TemplatePathParameter != null && !IsBusy); + RunTestCommand = new DelegateCommand(async () => await RunTestAsync(), CanRunTest); + GenerateTemplateFromSourceCommand = new DelegateCommand(OnGenerateTemplateFromSource, CanGenerateTemplateFromSource); + + EnsureDefaultSearchRegion(); + } + + private static bool IsHiddenSearchRoiParameter(string name) => + string.Equals(name, "SearchRegionX", StringComparison.Ordinal) || + string.Equals(name, "SearchRegionY", StringComparison.Ordinal) || + string.Equals(name, "SearchRegionWidth", StringComparison.Ordinal) || + string.Equals(name, "SearchRegionHeight", StringComparison.Ordinal); + + private static string? NormalizeRecipeDirectory(string? recipeDirectory) + { + if (string.IsNullOrWhiteSpace(recipeDirectory)) + return null; + + try + { + return Path.GetFullPath(recipeDirectory.Trim()); + } + catch + { + return null; + } + } + + public PipelineNodeViewModel Node { get; } + + public BitmapSource? SourceImage { get; } + + public ProcessorParameterVM? TemplatePathParameter { get; } + + public ObservableCollection OtherParameters { get; } + + /// 供画布刷新:任意 ROI 或搜索区参数变更时递增。 + public int RoiOverlayRevision => _roiOverlayRevision; + + private void BumpRoiOverlayRevision() + { + _roiOverlayRevision++; + RaisePropertyChanged(nameof(RoiOverlayRevision)); + } + + /// true = 编辑搜索框;false = 编辑模板框(类 VM)。 + public bool EditSearchRoiActive + { + get => _editSearchRoi; + set + { + if (SetProperty(ref _editSearchRoi, value)) + RaisePropertyChanged(nameof(EditTemplateRoiActive)); + } + } + + public bool EditTemplateRoiActive + { + get => !_editSearchRoi; + set => EditSearchRoiActive = !value; + } + + public bool IsBusy + { + get => _isBusy; + private set + { + if (SetProperty(ref _isBusy, value)) + { + BrowseTemplateCommand.RaiseCanExecuteChanged(); + RunTestCommand.RaiseCanExecuteChanged(); + GenerateTemplateFromSourceCommand.RaiseCanExecuteChanged(); + } + } + } + + public string RunResultText + { + get => _runResultText; + private set => SetProperty(ref _runResultText, value); + } + + public BitmapSource PreviewImage + { + get => _previewImage; + private set + { + if (SetProperty(ref _previewImage, value)) + HasPreview = value != null; + } + } + + public bool HasPreview + { + get => _hasPreview; + private set => SetProperty(ref _hasPreview, value); + } + + public bool HasSourceImage => SourceImage != null; + + public bool HasRecipeDirectory => !string.IsNullOrEmpty(_recipeDirectory); + + public string RecipeDirectoryHint => + HasRecipeDirectory + ? _recipeDirectory! + : "尚未关联已保存的配方文件:请先在链路编辑器中将配方「另存为」.xpm,再打开本工具生成模板。"; + + public string CropXText + { + get => _cropXText; + set + { + if (SetProperty(ref _cropXText, value)) + { + GenerateTemplateFromSourceCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(TemplateRegionDisplay)); + BumpRoiOverlayRevision(); + } + } + } + + public string CropYText + { + get => _cropYText; + set + { + if (SetProperty(ref _cropYText, value)) + { + GenerateTemplateFromSourceCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(TemplateRegionDisplay)); + BumpRoiOverlayRevision(); + } + } + } + + public string CropWidthText + { + get => _cropWidthText; + set + { + if (SetProperty(ref _cropWidthText, value)) + { + GenerateTemplateFromSourceCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(TemplateRegionDisplay)); + BumpRoiOverlayRevision(); + } + } + } + + public string CropHeightText + { + get => _cropHeightText; + set + { + if (SetProperty(ref _cropHeightText, value)) + { + GenerateTemplateFromSourceCommand.RaiseCanExecuteChanged(); + RaisePropertyChanged(nameof(TemplateRegionDisplay)); + BumpRoiOverlayRevision(); + } + } + } + + public string TemplateRegionDisplay + { + get + { + if (SourceImage == null) + return "未加载源图。"; + + return $"模板框(像素): X={CropXText}, Y={CropYText}, 宽={CropWidthText}, 高={CropHeightText}"; + } + } + + public string SearchRegionDisplay + { + get + { + if (SourceImage == null) + return string.Empty; + + if (!TryGetSearchRectangle(out int x, out int y, out int w, out int h)) + return "搜索框:未设置"; + + int iw = SourceImage.PixelWidth; + int ih = SourceImage.PixelHeight; + if (x == 0 && y == 0 && w == iw && h == ih) + return $"搜索框:整幅源图(默认,{iw}×{ih})"; + + return $"搜索框(像素): X={x}, Y={y}, 宽={w}, 高={h}"; + } + } + + public void SetCropPixels(int x, int y, int width, int height) + { + if (SourceImage == null) + return; + + int imgW = SourceImage.PixelWidth; + int imgH = SourceImage.PixelHeight; + if (imgW <= 0 || imgH <= 0) + return; + + x = Math.Clamp(x, 0, Math.Max(0, imgW - 1)); + y = Math.Clamp(y, 0, Math.Max(0, imgH - 1)); + width = Math.Clamp(width, 1, Math.Max(1, imgW - x)); + height = Math.Clamp(height, 1, Math.Max(1, imgH - y)); + + CropXText = x.ToString(CultureInfo.InvariantCulture); + CropYText = y.ToString(CultureInfo.InvariantCulture); + CropWidthText = width.ToString(CultureInfo.InvariantCulture); + CropHeightText = height.ToString(CultureInfo.InvariantCulture); + } + + public bool TryGetCropRectangle(out int x, out int y, out int w, out int h) + { + x = y = w = h = 0; + if (SourceImage == null) + return false; + + if (!int.TryParse(CropXText?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out x) || + !int.TryParse(CropYText?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out y) || + !int.TryParse(CropWidthText?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out w) || + !int.TryParse(CropHeightText?.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out h)) + return false; + + int imgW = SourceImage.PixelWidth; + int imgH = SourceImage.PixelHeight; + x = Math.Clamp(x, 0, Math.Max(0, imgW - 1)); + y = Math.Clamp(y, 0, Math.Max(0, imgH - 1)); + w = Math.Clamp(w, 1, Math.Max(1, imgW - x)); + h = Math.Clamp(h, 1, Math.Max(1, imgH - y)); + return true; + } + + public void SetSearchRoiPixels(int x, int y, int width, int height) + { + if (SourceImage == null) + return; + + int imgW = SourceImage.PixelWidth; + int imgH = SourceImage.PixelHeight; + if (imgW <= 0 || imgH <= 0) + return; + + x = Math.Clamp(x, 0, Math.Max(0, imgW - 1)); + y = Math.Clamp(y, 0, Math.Max(0, imgH - 1)); + width = Math.Clamp(width, 1, Math.Max(1, imgW - x)); + height = Math.Clamp(height, 1, Math.Max(1, imgH - y)); + + WriteIntParam(_searchRegionX, x); + WriteIntParam(_searchRegionY, y); + WriteIntParam(_searchRegionWidth, width); + WriteIntParam(_searchRegionHeight, height); + + RaisePropertyChanged(nameof(SearchRegionDisplay)); + BumpRoiOverlayRevision(); + } + + public bool TryGetSearchRectangle(out int x, out int y, out int w, out int h) + { + x = y = w = h = 0; + if (SourceImage == null) + return false; + + int imgW = SourceImage.PixelWidth; + int imgH = SourceImage.PixelHeight; + + var rx = ReadIntParam(_searchRegionX); + var ry = ReadIntParam(_searchRegionY); + var rw = ReadIntParam(_searchRegionWidth); + var rh = ReadIntParam(_searchRegionHeight); + + if (rw <= 0 || rh <= 0) + { + x = 0; + y = 0; + w = imgW; + h = imgH; + return true; + } + + x = Math.Clamp(rx, 0, Math.Max(0, imgW - 1)); + y = Math.Clamp(ry, 0, Math.Max(0, imgH - 1)); + w = Math.Clamp(rw, 1, Math.Max(1, imgW - x)); + h = Math.Clamp(rh, 1, Math.Max(1, imgH - y)); + return true; + } + + private void EnsureDefaultSearchRegion() + { + if (SourceImage == null || _searchRegionWidth == null || _searchRegionHeight == null) + return; + + var rw = ReadIntParam(_searchRegionWidth); + var rh = ReadIntParam(_searchRegionHeight); + if (rw > 0 && rh > 0) + return; + + SetSearchRoiPixels(0, 0, SourceImage.PixelWidth, SourceImage.PixelHeight); + } + + private static int ReadIntParam(ProcessorParameterVM? p) + { + if (p?.Value == null) + return 0; + + return p.Value switch + { + int i => i, + long l => (int)l, + double d => (int)Math.Round(d), + float f => (int)Math.Round(f), + string s when int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) => v, + _ => Convert.ToInt32(p.Value, CultureInfo.InvariantCulture) + }; + } + + private static void WriteIntParam(ProcessorParameterVM? p, int value) + { + if (p == null) + return; + + p.Value = value; + } + + public DelegateCommand BrowseTemplateCommand { get; } + + public DelegateCommand RunTestCommand { get; } + + public DelegateCommand GenerateTemplateFromSourceCommand { get; } + + private bool CanRunTest() => SourceImage != null && !IsBusy; + + private bool CanGenerateTemplateFromSource() => + SourceImage != null && + TemplatePathParameter != null && + HasRecipeDirectory && + !IsBusy; + + private void OnBrowseTemplate() + { + if (TemplatePathParameter == null) return; + + var dlg = new OpenFileDialog + { + Filter = "图像文件|*.png;*.jpg;*.jpeg;*.bmp;*.tif;*.tiff|所有文件|*.*", + Title = "选择模板图像" + }; + + if (dlg.ShowDialog() == true) + TemplatePathParameter.Value = dlg.FileName; + } + + private void OnGenerateTemplateFromSource() + { + if (!CanGenerateTemplateFromSource() || SourceImage == null || TemplatePathParameter == null || _recipeDirectory == null) + return; + + try + { + if (!TryGetCropRectangle(out int x, out int y, out int w, out int h)) + { + RunResultText = "请先在画布上框选模板区域,或调整模板框。"; + return; + } + + Directory.CreateDirectory(_recipeDirectory); + + var fileName = $"template_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; + var fullPath = Path.Combine(_recipeDirectory, fileName); + + var rect = new System.Windows.Int32Rect(x, y, w, h); + var cropped = new CroppedBitmap(SourceImage, rect); + cropped.Freeze(); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(cropped)); + using (var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + encoder.Save(stream); + + TemplatePathParameter.Value = fullPath; + RunResultText = $"学习完成:已生成模板 PNG 并写入路径:\n{fullPath}"; + _logger.Info("[模板匹配工具] 已从源图学习模板 {Path}", fullPath); + } + catch (Exception ex) + { + RunResultText = "学习模板失败:" + ex.Message; + _logger.Warn("[模板匹配工具] 学习模板失败: {Message}", ex.Message); + } + } + + private async Task RunTestAsync() + { + if (SourceImage == null || IsBusy) return; + + IsBusy = true; + RunResultText = string.Empty; + PreviewImage = null; + + try + { + var parameters = Node.Parameters.ToDictionary(p => p.Name, p => p.Value); + var (image, outputs) = await _imageProcessingService.ProcessImageWithOutputAsync( + SourceImage!, + OperatorKey, + parameters, + null, + CancellationToken.None).ConfigureAwait(true); + + PreviewImage = image; + RunResultText = BuildResultSummary(outputs); + _logger.Info("[模板匹配工具] 试跑完成 Matched={Matched}", outputs.TryGetValue("Matched", out var m) ? m : null); + BumpRoiOverlayRevision(); + } + catch (OperationCanceledException) + { + RunResultText = "已取消。"; + } + catch (Exception ex) + { + RunResultText = "试跑失败:" + ex.Message; + _logger.Warn("[模板匹配工具] 试跑失败: {Message}", ex.Message); + } + finally + { + IsBusy = false; + } + } + + private static string BuildResultSummary(IReadOnlyDictionary outputs) + { + var sb = new StringBuilder(); + + if (outputs.TryGetValue("Matched", out var matched)) + sb.AppendLine($"是否通过阈值: {FormatValue(matched)}"); + + if (outputs.TryGetValue("MatchScore", out var score)) + sb.AppendLine($"匹配得分: {FormatValue(score)}"); + + if (outputs.TryGetValue("MatchX", out var x) && outputs.TryGetValue("MatchY", out var y)) + sb.AppendLine($"最佳位置: ({FormatValue(x)}, {FormatValue(y)})"); + + if (outputs.TryGetValue("TemplateWidth", out var tw) && outputs.TryGetValue("TemplateHeight", out var th)) + sb.AppendLine($"模板尺寸: {FormatValue(tw)} × {FormatValue(th)}"); + + if (outputs.TryGetValue("MatchMethod", out var method) && method != null) + sb.AppendLine($"匹配方法: {method}"); + + if (outputs.TryGetValue("Message", out var msg) && msg != null && !string.IsNullOrWhiteSpace(msg.ToString())) + sb.AppendLine($"说明: {msg}"); + + return sb.ToString().TrimEnd(); + } + + private static string FormatValue(object? v) + { + if (v == null) return "—"; + return v switch + { + double d => d.ToString("F6", CultureInfo.CurrentCulture), + float f => f.ToString("F6", CultureInfo.CurrentCulture), + IFormattable fmt => fmt.ToString(null, CultureInfo.CurrentCulture), + _ => v.ToString() ?? string.Empty + }; + } + } +} diff --git a/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml b/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml index fdbb0bf..fd37fed 100644 --- a/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml +++ b/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml @@ -301,13 +301,41 @@ HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +