diff --git a/ExternalLibraries/TemplateMatchLib.dll b/ExternalLibraries/TemplateMatchLib.dll new file mode 100644 index 0000000..e5b214e Binary files /dev/null and b/ExternalLibraries/TemplateMatchLib.dll differ diff --git a/ExternalLibraries/opencv_world455.dll b/ExternalLibraries/opencv_world455.dll new file mode 100644 index 0000000..2861ef5 Binary files /dev/null and b/ExternalLibraries/opencv_world455.dll differ diff --git a/ExternalLibraries/opencv_world455d.dll b/ExternalLibraries/opencv_world455d.dll new file mode 100644 index 0000000..fae5344 Binary files /dev/null and b/ExternalLibraries/opencv_world455d.dll differ diff --git a/XP.Common/Resources/Resources.Designer.cs b/XP.Common/Resources/Resources.Designer.cs index 4a7faf8..a99fa51 100644 --- a/XP.Common/Resources/Resources.Designer.cs +++ b/XP.Common/Resources/Resources.Designer.cs @@ -780,159 +780,6 @@ 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 5264c76..3154237 100644 --- a/XP.Common/Resources/Resources.en-US.resx +++ b/XP.Common/Resources/Resources.en-US.resx @@ -489,57 +489,6 @@ 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 @@ -1518,6 +1467,90 @@ Thickness of drawing lines + + + + Rotated multi-target template matching + + + Rotation and multi-target matching via TemplateMatchLib, with pyramid levels and SIMD acceleration. + + + Template path + + + Path to the template image (grayscale or color; color is converted to grayscale). + + + Match threshold + + + Score threshold; typical range 0.7–0.95. + + + Max matches + + + Maximum number of targets to return. + + + Angle tolerance (°) + + + Angular search range in degrees; 0 disables rotation search. + + + Max overlap + + + Maximum allowed overlap ratio between multiple detections. + + + Min reduce area + + + Minimum pyramid level area (controls depth; smaller tends to be faster). + + + SIMD acceleration + + + Use SIMD (e.g. SSE) to accelerate matching. + + + Sub-pixel + + + Enable sub-pixel refinement (more accurate, slightly slower). + + + Draw results + + + Draw match rectangles and center marks on the output image. + + + Line thickness + + + Stroke width for rectangles and crosshairs (pixels). + + + Template file not found + + + Template learn failed + + + TemplateMatchLib.dll not found — build the C++ DLL project first. + + + Model path + + + Path to a pre-trained model file (.tmmodel). If it exists the model is loaded directly; otherwise the template is learned and the model is saved automatically. + + Angle Measurement diff --git a/XP.Common/Resources/Resources.resx b/XP.Common/Resources/Resources.resx index 6ad3846..7f0cc41 100644 --- a/XP.Common/Resources/Resources.resx +++ b/XP.Common/Resources/Resources.resx @@ -489,57 +489,6 @@ 绘制轮廓的线条粗细 - - 模板匹配 - - - 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 - - - 模板文件路径 - - - 磁盘上的模板图像路径(支持 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. - 除法运算 @@ -1541,6 +1490,89 @@ 绘制线条粗细 + + + 旋转多目标模板匹配 + + + 基于原生库(TemplateMatchLib)的旋转与多目标模板匹配,支持金字塔与 SIMD 加速。 + + + 模板路径 + + + 模板图像文件路径(灰度或彩色,将自动转为灰度)。 + + + 匹配阈值 + + + 匹配分数阈值,建议 0.7~0.95。 + + + 最大匹配数 + + + 最多检测的目标数量。 + + + 角度容差 + + + 角度搜索范围(度);0 表示不旋转搜索。 + + + 最大重叠 + + + 多目标之间允许的最大重叠比例。 + + + 最小缩减面积 + + + 金字塔最底层最小面积(控制层数;越小层数越多、通常越快)。 + + + SIMD 加速 + + + 是否使用 SIMD(如 SSE)加速匹配计算。 + + + 亚像素精度 + + + 是否启用亚像素估计(更精确,略慢)。 + + + 绘制结果 + + + 是否在输出图像上绘制匹配框与中心标记。 + + + 线条粗细 + + + 绘制矩形与十字的线条粗细(像素)。 + + + 未找到模板文件 + + + 模板学习失败 + + + 未找到 TemplateMatchLib.dll,请先编译 C++ DLL 工程。 + + + 模型路径 + + + 已训练模型文件路径(.tmmodel)。若存在则直接加载跳过学习;若不存在则从模板学习后自动保存。 + + 角度测量 diff --git a/XP.Common/Resources/Resources.zh-CN.resx b/XP.Common/Resources/Resources.zh-CN.resx index 87263c6..7dc08f9 100644 --- a/XP.Common/Resources/Resources.zh-CN.resx +++ b/XP.Common/Resources/Resources.zh-CN.resx @@ -489,57 +489,6 @@ 绘制轮廓的线条粗细 - - 模板匹配 - - - 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 - - - 模板文件路径 - - - 磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度) - - - 匹配方法 - - - OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) - - - 匹配阈值 - - - 相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3) - - - 绘制匹配矩形 - - - 匹配通过阈值时在输出图上用白框标出模板区域 - - - 矩形线宽 - - - 匹配框线宽(像素) - - - 搜索区域 X - - - 搜索区域 Y - - - 搜索区域宽度 - - - 搜索区域高度 - - - 在源图上的矩形搜索范围(像素)。宽或高为 0 时表示整幅图。由模板匹配工具写入。 - 除法运算 @@ -1518,6 +1467,84 @@ 绘制线条粗细 + + + + 旋转多目标模板匹配 + + + 基于原生库(TemplateMatchLib)的旋转与多目标模板匹配,支持金字塔与 SIMD 加速。 + + + 模板路径 + + + 模板图像文件路径(灰度或彩色,将自动转为灰度)。 + + + 匹配阈值 + + + 匹配分数阈值,建议 0.7~0.95。 + + + 最大匹配数 + + + 最多检测的目标数量。 + + + 角度容差 + + + 角度搜索范围(度);0 表示不旋转搜索。 + + + 最大重叠 + + + 多目标之间允许的最大重叠比例。 + + + 最小缩减面积 + + + 金字塔最底层最小面积(控制层数;越小层数越多、通常越快)。 + + + SIMD 加速 + + + 是否使用 SIMD(如 SSE)加速匹配计算。 + + + 亚像素精度 + + + 是否启用亚像素估计(更精确,略慢)。 + + + 绘制结果 + + + 是否在输出图像上绘制匹配框与中心标记。 + + + 线条粗细 + + + 绘制矩形与十字的线条粗细(像素)。 + + + 未找到模板文件 + + + 模板学习失败 + + + 未找到 TemplateMatchLib.dll,请先编译 C++ DLL 工程。 + + 角度测量 diff --git a/XP.Common/Resources/Resources.zh-TW.resx b/XP.Common/Resources/Resources.zh-TW.resx index 7eb25ae..946e1f5 100644 --- a/XP.Common/Resources/Resources.zh-TW.resx +++ b/XP.Common/Resources/Resources.zh-TW.resx @@ -489,57 +489,6 @@ 绘制轮廓的线条粗细 - - 模板匹配 - - - 在整幅灰階圖中搜尋模板影像的最佳位置,可選繪製匹配框 - - - 模板檔案路徑 - - - 磁碟上的模板影像路徑(支援 bmp/png/jpg 等,彩色將轉為灰階) - - - 匹配方法 - - - OpenCV 模板匹配類型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) - - - 匹配閾值 - - - 相關類方法:得分需≥該值判為匹配;Sqdiff/SqdiffNormed:得分需≤該值(建議 Normed 時 0.1~0.3) - - - 繪製匹配矩形 - - - 匹配通過閾值時在輸出圖上以白框標出模板區域 - - - 矩形線寬 - - - 匹配框線寬(像素) - - - 搜尋區域 X - - - 搜尋區域 Y - - - 搜尋區域寬度 - - - 搜尋區域高度 - - - 在源圖上的矩形搜尋範圍(像素)。寬或高為 0 表示整幅圖。由模板匹配工具寫入。 - 除法运算 @@ -1518,6 +1467,84 @@ 绘制线条粗细 + + + + 旋轉多目標模板匹配 + + + 以原生函式庫(TemplateMatchLib)實作旋轉與多目標模板匹配,支援金字塔與 SIMD 加速。 + + + 模板路徑 + + + 模板影像檔路徑(灰階或彩色;彩色將自動轉為灰階)。 + + + 匹配閾值 + + + 匹配分數閾值,建議 0.7~0.95。 + + + 最大匹配數 + + + 最多偵測的目標數量。 + + + 角度容差 + + + 角度搜尋範圍(度);0 表示不進行旋轉搜尋。 + + + 最大重疊 + + + 多目標之間允許的最大重疊比例。 + + + 最小縮減面積 + + + 金字塔最底層最小面積(控制層數;越小層數越多、通常越快)。 + + + SIMD 加速 + + + 是否使用 SIMD(如 SSE)加速匹配計算。 + + + 亞像素精度 + + + 是否啟用亞像素估計(較精確,略慢)。 + + + 繪製結果 + + + 是否在輸出影像上繪製匹配框與中心標記。 + + + 線條粗細 + + + 繪製矩形與十字的線條粗細(像素)。 + + + 找不到模板檔案 + + + 模板學習失敗 + + + 找不到 TemplateMatchLib.dll,請先編譯 C++ DLL 專案。 + + 角度测量 diff --git a/XP.ImageProcessing.Processors/定位识别/RotatedTemplateMatchingProcessor.cs b/XP.ImageProcessing.Processors/定位识别/RotatedTemplateMatchingProcessor.cs new file mode 100644 index 0000000..8240191 --- /dev/null +++ b/XP.ImageProcessing.Processors/定位识别/RotatedTemplateMatchingProcessor.cs @@ -0,0 +1,271 @@ +// ============================================================================ +// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved. +// 文件名: RotatedTemplateMatchingProcessor.cs +// 描述: 旋转多目标模板匹配(金字塔、SIMD、亚像素) +// 功能: +// - 调用 TemplateMatchLib.dll 实现高性能旋转模板匹配 +// - 支持多目标检测和重叠过滤 +// - 支持图像金字塔加速 +// - 支持 SIMD 加速和亚像素精度 +// - 输出匹配结果(中心坐标、角度、分数、四角坐标) +// ============================================================================ + +using System; +using Emgu.CV; +using Emgu.CV.Structure; +using XP.ImageProcessing.Core; +using Serilog; + +namespace XP.ImageProcessing.Processors; + +/// +/// 旋转多目标模板匹配(定位识别),基于 TemplateMatchLib 原生库。 +/// +public class RotatedTemplateMatchingProcessor : ImageProcessorBase +{ + private static readonly ILogger _logger = Log.ForContext(); + + public RotatedTemplateMatchingProcessor() + { + Name = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Name"); + Description = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Description"); + } + + protected override void InitializeParameters() + { + Parameters.Add("TemplatePath", new ProcessorParameter( + "TemplatePath", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_TemplatePath"), + typeof(string), + string.Empty, + null, + null, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_TemplatePath_Desc"))); + + Parameters.Add("MatchThreshold", new ProcessorParameter( + "MatchThreshold", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MatchThreshold"), + typeof(double), + 0.75, + 0.0, + 1.0, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MatchThreshold_Desc"))); + + Parameters.Add("MaxMatchCount", new ProcessorParameter( + "MaxMatchCount", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MaxMatchCount"), + typeof(int), + 1, + 1, + 100, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MaxMatchCount_Desc"))); + + Parameters.Add("ToleranceAngle", new ProcessorParameter( + "ToleranceAngle", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_ToleranceAngle"), + typeof(double), + 0.0, + 0.0, + 180.0, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_ToleranceAngle_Desc"))); + + Parameters.Add("MaxOverlap", new ProcessorParameter( + "MaxOverlap", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MaxOverlap"), + typeof(double), + 0.3, + 0.0, + 1.0, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MaxOverlap_Desc"))); + + Parameters.Add("MinReduceArea", new ProcessorParameter( + "MinReduceArea", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MinReduceArea"), + typeof(int), + 256, + 64, + 4096, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_MinReduceArea_Desc"))); + + Parameters.Add("UseSIMD", new ProcessorParameter( + "UseSIMD", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_UseSIMD"), + typeof(bool), + true, + null, + null, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_UseSIMD_Desc"))); + + Parameters.Add("UseSubPixel", new ProcessorParameter( + "UseSubPixel", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_UseSubPixel"), + typeof(bool), + false, + null, + null, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_UseSubPixel_Desc"))); + + Parameters.Add("DrawResults", new ProcessorParameter( + "DrawResults", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_DrawResults"), + typeof(bool), + true, + null, + null, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_DrawResults_Desc"))); + + Parameters.Add("DrawThickness", new ProcessorParameter( + "DrawThickness", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_DrawThickness"), + typeof(int), + 1, + 1, + 8, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_DrawThickness_Desc"))); + + Parameters.Add("ModelPath", new ProcessorParameter( + "ModelPath", + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_ModelPath"), + typeof(string), + string.Empty, + null, + null, + LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_ModelPath_Desc"))); + } + + public override Image Process(Image inputImage) + { + var path = (GetParameter("TemplatePath") ?? string.Empty).Trim(); + var modelPath = (GetParameter("ModelPath") ?? string.Empty).Trim(); + var threshold = GetParameter("MatchThreshold"); + var maxCount = GetParameter("MaxMatchCount"); + var toleranceAngle = GetParameter("ToleranceAngle"); + var maxOverlap = GetParameter("MaxOverlap"); + var minReduceArea = GetParameter("MinReduceArea"); + var useSIMD = GetParameter("UseSIMD"); + var useSubPixel = GetParameter("UseSubPixel"); + + OutputData.Clear(); + var output = inputImage.Clone(); + + // 模板路径和模型路径都为空时报错 + bool hasModel = !string.IsNullOrEmpty(modelPath) && System.IO.File.Exists(modelPath); + bool hasTemplate = !string.IsNullOrEmpty(path) && System.IO.File.Exists(path); + + if (!hasModel && !hasTemplate) + { + _logger.Warning("RotatedTemplateMatching: no template or model file found"); + OutputData["Matched"] = false; + OutputData["MatchCount"] = 0; + OutputData["Message"] = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Msg_TemplateNotFound"); + return output; + } + + try + { + using var matcher = new TemplateMatcherHandle(); + + // 优先加载模型文件,否则从模板图片学习 + bool modelLoaded = false; + if (hasModel) + { + modelLoaded = matcher.LoadModel(modelPath); + if (modelLoaded) + _logger.Debug("RotatedTemplateMatching: loaded model from {Path}", modelPath); + } + + if (!modelLoaded) + { + if (!hasTemplate) + { + OutputData["Matched"] = false; + OutputData["MatchCount"] = 0; + OutputData["Message"] = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Msg_TemplateNotFound"); + return output; + } + + if (!matcher.LearnPatternFromFile(path)) + { + OutputData["Matched"] = false; + OutputData["MatchCount"] = 0; + OutputData["Message"] = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Msg_TemplateLearnFailed"); + return output; + } + + // 学习成功后自动保存模型 + if (!string.IsNullOrEmpty(modelPath)) + { + var dir = System.IO.Path.GetDirectoryName(modelPath); + if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir)) + System.IO.Directory.CreateDirectory(dir); + + if (matcher.SaveModel(modelPath)) + _logger.Information("RotatedTemplateMatching: model saved to {Path}", modelPath); + else + _logger.Warning("RotatedTemplateMatching: failed to save model to {Path}", modelPath); + } + } + + var param = new TM_Params + { + Score = threshold, + ToleranceAngle = toleranceAngle, + MaxOverlap = maxOverlap, + MaxCount = maxCount, + MinReduceArea = minReduceArea, + UseSIMD = useSIMD ? 1 : 0, + UseSubPixel = useSubPixel ? 1 : 0 + }; + + IntPtr srcData = inputImage.Mat.DataPointer; + int srcWidth = inputImage.Width; + int srcHeight = inputImage.Height; + int srcStep = (int)inputImage.Mat.Step; + + var results = matcher.Match(srcData, srcWidth, srcHeight, srcStep, param); + + OutputData["Matched"] = results.Length > 0; + OutputData["MatchCount"] = results.Length; + OutputData["MatchTime"] = matcher.LastMatchTime; + + for (int i = 0; i < results.Length; i++) + { + var r = results[i]; + string prefix = results.Length == 1 ? "" : $"[{i}]"; + OutputData[$"CenterX{prefix}"] = r.CenterX; + OutputData[$"CenterY{prefix}"] = r.CenterY; + OutputData[$"Angle{prefix}"] = r.Angle; + OutputData[$"Score{prefix}"] = r.Score; + OutputData[$"LtX{prefix}"] = r.LtX; + OutputData[$"LtY{prefix}"] = r.LtY; + OutputData[$"RtX{prefix}"] = r.RtX; + OutputData[$"RtY{prefix}"] = r.RtY; + OutputData[$"RbX{prefix}"] = r.RbX; + OutputData[$"RbY{prefix}"] = r.RbY; + OutputData[$"LbX{prefix}"] = r.LbX; + OutputData[$"LbY{prefix}"] = r.LbY; + } + + _logger.Debug("RotatedTemplateMatching: Found {Count} matches in {Time:F1}ms", + results.Length, matcher.LastMatchTime); + + return output; + } + catch (DllNotFoundException ex) + { + _logger.Error(ex, "RotatedTemplateMatching: TemplateMatchLib.dll not found"); + OutputData["Matched"] = false; + OutputData["MatchCount"] = 0; + OutputData["Message"] = LocalizationHelper.GetString("RotatedTemplateMatchingProcessor_Msg_DllNotFound"); + return output; + } + catch (Exception ex) + { + _logger.Error(ex, "RotatedTemplateMatching: unexpected error"); + OutputData["Matched"] = false; + OutputData["MatchCount"] = 0; + OutputData["Message"] = ex.Message; + return output; + } + } +} diff --git a/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs b/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs new file mode 100644 index 0000000..35e0364 --- /dev/null +++ b/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs @@ -0,0 +1,249 @@ +// ============================================================================ +// TemplateMatchNative.cs +// C++ DLL P/Invoke 封装层 +// 提供对 TemplateMatchLib.dll 的托管调用接口 +// ============================================================================ + +using System; +using System.Runtime.InteropServices; + +namespace XP.ImageProcessing.Processors; + +/// +/// 匹配参数(与C++ TM_Params对应) +/// +[StructLayout(LayoutKind.Sequential)] +public struct TM_Params +{ + /// 匹配阈值 (0~1) + public double Score; + /// 角度容差 (度),0表示不旋转 + public double ToleranceAngle; + /// 最大重叠比例 (0~1) + public double MaxOverlap; + /// 最大匹配数 + public int MaxCount; + /// 金字塔最小面积,默认256 + public int MinReduceArea; + /// 是否使用SIMD加速 (1=是, 0=否) + public int UseSIMD; + /// 是否亚像素估计 (1=是, 0=否) + public int UseSubPixel; + + /// + /// 创建默认参数 + /// + public static TM_Params Default => new TM_Params + { + Score = 0.75, + ToleranceAngle = 0, + MaxOverlap = 0.3, + MaxCount = 1, + MinReduceArea = 256, + UseSIMD = 1, + UseSubPixel = 0 + }; +} + +/// +/// 单个匹配结果(与C++ TM_Result对应) +/// +[StructLayout(LayoutKind.Sequential)] +public struct TM_Result +{ + /// 匹配中心X + public double CenterX; + /// 匹配中心Y + public double CenterY; + /// 匹配角度 (度) + public double Angle; + /// 匹配分数 + public double Score; + /// 左上角X + public double LtX; + /// 左上角Y + public double LtY; + /// 右上角X + public double RtX; + /// 右上角Y + public double RtY; + /// 右下角X + public double RbX; + /// 右下角Y + public double RbY; + /// 左下角X + public double LbX; + /// 左下角Y + public double LbY; +} + +/// +/// TemplateMatchLib.dll P/Invoke 接口 +/// +public static class TemplateMatchNative +{ + private const string DllName = "TemplateMatchLib.dll"; + + /// 创建匹配器实例 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr TM_Create(); + + /// 销毁匹配器实例 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern void TM_Destroy(IntPtr handle); + + /// 从内存数据学习模板 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int TM_LearnPattern(IntPtr handle, + IntPtr templateData, int width, int height, int step); + + /// 从文件学习模板 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern int TM_LearnPatternFromFile(IntPtr handle, + [MarshalAs(UnmanagedType.LPStr)] string filePath); + + /// 执行模板匹配 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int TM_Match(IntPtr handle, + IntPtr srcData, int srcWidth, int srcHeight, int srcStep, + ref TM_Params param, + [Out] TM_Result[] results, int maxResults); + + /// 获取上次匹配耗时(毫秒) + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern double TM_GetLastMatchTime(IntPtr handle); + + /// 获取模板信息 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int TM_GetTemplateInfo(IntPtr handle, + out int width, out int height, out int pyramidLayers); + + /// 保存训练好的模型到文件 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern int TM_SaveModel(IntPtr handle, + [MarshalAs(UnmanagedType.LPStr)] string filePath); + + /// 从文件加载已训练的模型 + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern int TM_LoadModel(IntPtr handle, + [MarshalAs(UnmanagedType.LPStr)] string filePath); +} + +/// +/// 模板匹配器托管封装(自动管理非托管资源) +/// +public sealed class TemplateMatcherHandle : IDisposable +{ + private IntPtr _handle; + private bool _disposed; + + public TemplateMatcherHandle() + { + _handle = TemplateMatchNative.TM_Create(); + if (_handle == IntPtr.Zero) + throw new InvalidOperationException("Failed to create TemplateMatcher instance"); + } + + /// + /// 从文件学习模板 + /// + public bool LearnPatternFromFile(string filePath) + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_LearnPatternFromFile(_handle, filePath) == 0; + } + + /// + /// 从EmguCV Image学习模板 + /// + public bool LearnPattern(IntPtr data, int width, int height, int step) + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_LearnPattern(_handle, data, width, height, step) == 0; + } + + /// + /// 执行匹配 + /// + public TM_Result[] Match(IntPtr srcData, int srcWidth, int srcHeight, int srcStep, TM_Params param) + { + ThrowIfDisposed(); + var results = new TM_Result[param.MaxCount]; + int count = TemplateMatchNative.TM_Match(_handle, srcData, srcWidth, srcHeight, srcStep, + ref param, results, param.MaxCount); + + if (count <= 0) + return Array.Empty(); + + if (count < results.Length) + Array.Resize(ref results, count); + return results; + } + + /// + /// 获取上次匹配耗时(毫秒) + /// + public double LastMatchTime + { + get + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_GetLastMatchTime(_handle); + } + } + + /// + /// 获取模板信息 + /// + public bool GetTemplateInfo(out int width, out int height, out int pyramidLayers) + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_GetTemplateInfo(_handle, out width, out height, out pyramidLayers) == 0; + } + + /// + /// 保存训练好的模型到文件 + /// + /// 模型文件路径(建议扩展名 .tmmodel) + /// 是否成功 + public bool SaveModel(string filePath) + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_SaveModel(_handle, filePath) == 0; + } + + /// + /// 从文件加载已训练的模型(跳过LearnPattern) + /// + /// 模型文件路径 + /// 是否成功 + public bool LoadModel(string filePath) + { + ThrowIfDisposed(); + return TemplateMatchNative.TM_LoadModel(_handle, filePath) == 0; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(TemplateMatcherHandle)); + } + + public void Dispose() + { + if (!_disposed) + { + if (_handle != IntPtr.Zero) + { + TemplateMatchNative.TM_Destroy(_handle); + _handle = IntPtr.Zero; + } + _disposed = true; + } + } + + ~TemplateMatcherHandle() + { + Dispose(); + } +} diff --git a/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs b/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs deleted file mode 100644 index a794c81..0000000 --- a/XP.ImageProcessing.Processors/定位识别/TemplateMatchingProcessor.cs +++ /dev/null @@ -1,435 +0,0 @@ -// ============================================================================ -// 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; - -/// -/// 模板匹配算子(定位识别) -/// -/// -/// 算法原理: -/// 模板匹配是一种基于图像块的匹配方法,通过在待搜索图像上滑动模板, -/// 计算每个位置与模板的相似度,找到最佳匹配位置。 -/// -/// 匹配方法说明: -/// 1. CcoeffNormed (归一化相关系数) - 推荐使用,对光照变化有一定的鲁棒性 -/// 公式: corr = Σ(I(x,y) * T(x,y)) / sqrt(ΣI² * ΣT²) -/// 分数范围: -1 ~ 1,值越大越相似 -/// -/// 2. SqdiffNormed (归一化平方差) - 值越小越相似 -/// 公式: diff = Σ(I(x,y) - T(x,y))² / (ΣI² + ΣT²) -/// 分数范围: 0 ~ 1,值越小越相似 -/// -/// 3. CcorrNormed (归一化相关) - 对模板和图像的亮度变化敏感 -/// 4. Ccoeff (相关系数) - 未归一化版本 -/// 5. Ccorr (相关) - 未归一化版本 -/// 6. Sqdiff (平方差) - 未归一化版本 -/// -/// 性能说明: -/// - 时间复杂度: O(W * H * w * h),其中W/H为图像尺寸,w/h为模板尺寸 -/// - 可通过设置SearchRegion限制搜索范围来提升性能 -/// -public class TemplateMatchingProcessor : ImageProcessorBase -{ - private static readonly ILogger _logger = Log.ForContext(); - - /// - /// 匹配方法选项列表,供UI下拉选择 - /// - 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)); - - /// - /// 匹配阈值,判断匹配是否成功的分数门限 - /// - CcoeffNormed/CcorrNormed: 建议 0.75-0.95 - /// - SqdiffNormed: 建议 0.1-0.3 - /// - 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"))); - - // ===== 搜索区域参数(可选,用于限制搜索范围提升性能)===== - - /// - /// 搜索区域左上角X坐标 - /// - Parameters.Add("SearchRegionX", new ProcessorParameter( - "SearchRegionX", - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionX"), - typeof(int), - 0, - 0, - null, - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); - - /// - /// 搜索区域左上角Y坐标 - /// - Parameters.Add("SearchRegionY", new ProcessorParameter( - "SearchRegionY", - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionY"), - typeof(int), - 0, - 0, - null, - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); - - /// - /// 搜索区域宽度,0表示使用整幅图像 - /// - Parameters.Add("SearchRegionWidth", new ProcessorParameter( - "SearchRegionWidth", - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionWidth"), - typeof(int), - 0, - 0, - null, - LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc"))); - - /// - /// 搜索区域高度,0表示使用整幅图像 - /// - 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) - { - // ===== 1. 获取参数 ===== - 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(); - - // ===== 2. 参数校验:模板文件 ===== - 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; - } - - // ===== 3. 加载模板图像 ===== - using var template = LoadTemplate(path); - if (template == null) - { - OutputData["Matched"] = false; - OutputData["Message"] = "Template load failed"; - return output; - } - - // ===== 4. 确定搜索区域(ROI)===== - // 如果未指定搜索区域,则使用整幅图像 - var searchRoi = ResolveSearchRoi(inputImage.Width, inputImage.Height, searchRx, searchRy, searchRw, searchRh); - var offsetX = searchRoi.X; // 记录ROI偏移,用于还原到全局坐标 - var offsetY = searchRoi.Y; - - // ===== 5. 在ROI区域内执行模板匹配 ===== - Image? roiImage = null; - try - { - // 设置输入图像的ROI(感兴趣区域) - inputImage.ROI = searchRoi; - // 复制ROI区域到新图像 - roiImage = inputImage.Copy(); - // 清除ROI设置 - inputImage.ROI = Rectangle.Empty; - - // ===== 5.1 校验模板尺寸 ===== - 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; - } - - // ===== 5.2 执行模板匹配 ===== - // OpenCV MatchTemplate 在ROI上滑动模板,计算每个位置的相似度 - // 结果是一个 (W-w+1) x (H-h+1) 的分数矩阵 - var method = ParseMethod(methodName); - using var resultMat = new Mat(); - CvInvoke.MatchTemplate(roiImage, template, resultMat, method); - - // ===== 5.3 找到最佳匹配位置 ===== - // MinMaxLoc 在分数矩阵中找到最小值和最大值的位置 - double minVal = 0, maxVal = 0; - Point minLoc = default, maxLoc = default; - CvInvoke.MinMaxLoc(resultMat, ref minVal, ref maxVal, ref minLoc, ref maxLoc); - - // 根据匹配方法选择使用最小值还是最大值 - // 平方差类方法:值越小越好(使用minLoc) - // 相关类方法:值越大越好(使用maxLoc) - var useMin = method == TemplateMatchingType.Sqdiff || method == TemplateMatchingType.SqdiffNormed; - var loc = useMin ? minLoc : maxLoc; - var score = useMin ? minVal : maxVal; - - // ===== 5.4 判定匹配是否成功 ===== - var matched = IsMatchAcceptable(method, minVal, maxVal, threshold); - - // ===== 5.5 转换到全局坐标 ===== - // 由于是在ROI内匹配的,需要加上ROI的偏移量 - var globalLoc = new Point(loc.X + offsetX, loc.Y + offsetY); - - // ===== 5.6 输出结果数据 ===== - 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; - - // ===== 5.7 可选:绘制匹配矩形 ===== - 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 - { - // 释放ROI图像资源 - roiImage?.Dispose(); - } - } - - /// - /// 计算实际的搜索ROI区域 - /// - /// 图像宽度 - /// 图像高度 - /// 用户指定的ROI X坐标 - /// 用户指定的ROI Y坐标 - /// 用户指定的ROI宽度,0表示整幅图像 - /// 用户指定的ROI高度,0表示整幅图像 - /// 计算后的有效ROI区域 - private static Rectangle ResolveSearchRoi(int imgW, int imgH, int rx, int ry, int rw, int rh) - { - // 宽度或高度为0时,使用整幅图像作为搜索区域 - 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); - } - - /// - /// 判断匹配结果是否满足阈值条件 - /// - /// 匹配方法类型 - /// 最小相似度分数 - /// 最大相似度分数 - /// 用户设定的阈值 - /// 是否满足匹配条件 - /// - /// 不同匹配方法的分数含义不同: - /// - 平方差类(Sqdiff/SqdiffNormed): 分数越小越相似,需要 minVal <= threshold - /// - 相关类(Ccoeff/Ccorr/CcoeffNormed/CcorrNormed): 分数越大越相似,需要 maxVal >= threshold - /// - 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 - }; - } - - /// - /// 将字符串方法名转换为OpenCV枚举类型 - /// - /// 方法名称字符串 - /// 对应的TemplateMatchingType枚举值 - 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 - }; - } - - /// - /// 从磁盘加载模板图像并转换为灰度图 - /// - /// 模板图片文件路径 - /// 灰度模板图像,加载失败返回null - /// - /// 支持的输入格式: - /// - 灰度图像:直接使用 - /// - 彩色图像:自动转换为灰度 - /// - 支持任意位深度的图像 - /// - private static Image? LoadTemplate(string path) - { - try - { - // 使用任意格式读取(支持灰度、彩色、16位等) - 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 - { - // 彩色图转灰度 (BGR -> Gray) - 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/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs index 9fefa9f..b4b9710 100644 --- a/XplorePlane/Services/Cnc/CncExecutionService.cs +++ b/XplorePlane/Services/Cnc/CncExecutionService.cs @@ -348,8 +348,9 @@ namespace XplorePlane.Services.Cnc try { var pipelineNodes = BuildPipelineNodeViewModels(inspectionNode.Pipeline); - resultImage = await _pipelineExecutionService.ExecutePipelineAsync( + var execResult = await _pipelineExecutionService.ExecutePipelineAsync( pipelineNodes, sourceImage, null, cancellationToken); + resultImage = execResult.Image; if (resultImage != null) { diff --git a/XplorePlane/Services/ImageProcessing/ImageConverter.cs b/XplorePlane/Services/ImageProcessing/ImageConverter.cs index 539e7d1..ce28cfd 100644 --- a/XplorePlane/Services/ImageProcessing/ImageConverter.cs +++ b/XplorePlane/Services/ImageProcessing/ImageConverter.cs @@ -68,4 +68,4 @@ namespace XplorePlane.Services return BitmapSource.Create(width, height, 96, 96, PixelFormats.Gray8, null, pixels, stride); } } -} \ No newline at end of file +} diff --git a/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs b/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs index 966f97b..16235db 100644 --- a/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs +++ b/XplorePlane/Services/ImageProcessing/ProcessorUiMetadata.cs @@ -35,6 +35,10 @@ namespace XplorePlane.Services "SubPixel", "SuperResolution", "HDR", "Effect", "PseudoColor", "Color")) return "图像增强"; + // 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate + if (ContainsAny(operatorKey, "RotatedTemplateMatching")) + return "定位识别"; + if (ContainsAny(operatorKey, "Mirror", "Rotate", "Grayscale", "Threshold")) return "图像变换"; @@ -47,9 +51,6 @@ 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 "检测分析"; @@ -111,6 +112,9 @@ namespace XplorePlane.Services return "🎞"; if (ContainsAny(operatorKey, "ColorLayer")) return "🧪"; + // 必须在 "Rotate" 之前:RotatedTemplateMatching 含子串 Rotate + if (ContainsAny(operatorKey, "RotatedTemplateMatching")) + return "🎯"; if (ContainsAny(operatorKey, "Mirror")) return "↔"; if (ContainsAny(operatorKey, "Rotate")) @@ -133,8 +137,6 @@ 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/Services/ImageProcessing/TemplateMatchOverlayRenderer.cs b/XplorePlane/Services/ImageProcessing/TemplateMatchOverlayRenderer.cs new file mode 100644 index 0000000..d9b9a14 --- /dev/null +++ b/XplorePlane/Services/ImageProcessing/TemplateMatchOverlayRenderer.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace XplorePlane.Services +{ + /// + /// 旋转模板匹配结果:在透明背景上绘制 1 像素绿色线框与中心十字,供 PolygonRoiCanvas 叠加层使用(与 BGA 工具分层方式一致)。 + /// + public static class TemplateMatchOverlayRenderer + { + public const string ProcessorKey = "RotatedTemplateMatching"; + private const int CrossHalfLength = 10; + + /// + /// 若最后一步为旋转模板匹配且需绘制、且有匹配,则复制一份 OutputData 供 UI 叠加层使用;否则返回 null。 + /// + public static Dictionary? TrySnapshotOutputForOverlay( + string operatorKey, + IReadOnlyDictionary parameters, + IReadOnlyDictionary outputData) + { + if (!string.Equals(operatorKey, ProcessorKey, StringComparison.OrdinalIgnoreCase)) + return null; + + if (!IsDrawResultsEnabled(parameters)) + return null; + + if (!TryGetMatchCount(outputData, out int c) || c <= 0) + return null; + + var copy = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in outputData) + copy[kv.Key] = kv.Value; + return copy; + } + + /// + /// 生成与底图同尺寸的 Pbgra32 透明叠加图(仅绿色线)。 + /// + public static BitmapSource? CreateTransparentOverlay( + int pixelWidth, + int pixelHeight, + IReadOnlyDictionary? outputData) + { + if (pixelWidth <= 0 || pixelHeight <= 0 || outputData == null) + return null; + if (!TryGetMatchCount(outputData, out int count) || count <= 0) + return null; + + var green = new SolidColorBrush(Color.FromRgb(0, 255, 0)); + green.Freeze(); + var pen = new Pen(green, 1) + { + StartLineCap = PenLineCap.Flat, + EndLineCap = PenLineCap.Flat + }; + pen.Freeze(); + + var dv = new DrawingVisual(); + using (var dc = dv.RenderOpen()) + { + for (int i = 0; i < count; i++) + { + var pfx = count == 1 ? "" : $"[{i}]"; + if (!TryReadCorners(outputData, pfx, out var ltX, out var ltY, out var rtX, out var rtY, + out var rbX, out var rbY, out var lbX, out var lbY)) + continue; + + var lt = new Point(ltX, ltY); + var rt = new Point(rtX, rtY); + var rb = new Point(rbX, rbY); + var lb = new Point(lbX, lbY); + + dc.DrawLine(pen, lt, rt); + dc.DrawLine(pen, rt, rb); + dc.DrawLine(pen, rb, lb); + dc.DrawLine(pen, lb, lt); + + if (TryReadCenter(outputData, pfx, out var cx, out var cy)) + { + int h = CrossHalfLength; + dc.DrawLine(pen, new Point(cx - h, cy), new Point(cx + h, cy)); + dc.DrawLine(pen, new Point(cx, cy - h), new Point(cx, cy + h)); + } + } + } + + var rtb = new RenderTargetBitmap(pixelWidth, pixelHeight, 96, 96, PixelFormats.Pbgra32); + rtb.Render(dv); + rtb.Freeze(); + return rtb; + } + + private static bool IsDrawResultsEnabled(IReadOnlyDictionary parameters) + { + if (parameters == null || !parameters.TryGetValue("DrawResults", out var v)) + return true; + + if (v is bool b) + return b; + + try + { + return Convert.ToBoolean(v); + } + catch + { + return true; + } + } + + private static bool TryGetMatchCount(IReadOnlyDictionary d, out int count) + { + count = 0; + if (!d.TryGetValue("MatchCount", out var o) || o == null) + return false; + try + { + count = Convert.ToInt32(o); + return count > 0; + } + catch + { + return false; + } + } + + private static bool TryReadDouble(IReadOnlyDictionary d, string key, out double v) + { + v = default; + if (!d.TryGetValue(key, out var o) || o == null) + return false; + try + { + v = Convert.ToDouble(o); + return true; + } + catch + { + return false; + } + } + + private static bool TryReadCorners(IReadOnlyDictionary d, string pfx, + out double ltX, out double ltY, out double rtX, out double rtY, + out double rbX, out double rbY, out double lbX, out double lbY) + { + ltX = ltY = rtX = rtY = rbX = rbY = lbX = lbY = 0; + return TryReadDouble(d, "LtX" + pfx, out ltX) + && TryReadDouble(d, "LtY" + pfx, out ltY) + && TryReadDouble(d, "RtX" + pfx, out rtX) + && TryReadDouble(d, "RtY" + pfx, out rtY) + && TryReadDouble(d, "RbX" + pfx, out rbX) + && TryReadDouble(d, "RbY" + pfx, out rbY) + && TryReadDouble(d, "LbX" + pfx, out lbX) + && TryReadDouble(d, "LbY" + pfx, out lbY); + } + + private static bool TryReadCenter(IReadOnlyDictionary d, string pfx, out double cx, out double cy) + { + cx = cy = 0; + return TryReadDouble(d, "CenterX" + pfx, out cx) && TryReadDouble(d, "CenterY" + pfx, out cy); + } + } +} diff --git a/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs b/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs index 2ae801f..76d9825 100644 --- a/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs +++ b/XplorePlane/Services/Pipeline/IPipelineExecutionService.cs @@ -9,9 +9,15 @@ namespace XplorePlane.Services { public record PipelineProgress(int CurrentStep, int TotalSteps, string CurrentOperator); + /// 流水线输出图像(始终为灰度预览路径下的结果)。 + /// 当且仅当最后一步为旋转模板匹配且需绘制匹配框时,为 OutputData 的副本,供 UI 透明叠加层使用;否则为 null。 + public sealed record PipelineExecutionResult( + BitmapSource Image, + IReadOnlyDictionary? TemplateMatchOverlayData); + public interface IPipelineExecutionService { - Task ExecutePipelineAsync( + Task ExecutePipelineAsync( IEnumerable nodes, BitmapSource source, IProgress progress = null, diff --git a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs index 648c42a..b28c2c8 100644 --- a/XplorePlane/Services/Pipeline/PipelineExecutionService.cs +++ b/XplorePlane/Services/Pipeline/PipelineExecutionService.cs @@ -21,7 +21,7 @@ namespace XplorePlane.Services _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); } - public async Task ExecutePipelineAsync( + public async Task ExecutePipelineAsync( IEnumerable nodes, BitmapSource source, IProgress progress = null, @@ -35,11 +35,13 @@ namespace XplorePlane.Services .ToList(); if (enabledNodes.Count == 0) - return source; + return new PipelineExecutionResult(source, null); var current = ScaleForPreview(source); var total = enabledNodes.Count; + IReadOnlyDictionary? templateOverlayData = null; + for (var step = 0; step < total; step++) { cancellationToken.ThrowIfCancellationRequested(); @@ -63,9 +65,11 @@ namespace XplorePlane.Services try { - current = await _imageProcessingService.ProcessImageAsync( + var (img, output) = await _imageProcessingService.ProcessImageWithOutputAsync( current, node.OperatorKey, parameters, null, cancellationToken); + current = img; + if (current == null) { throw new PipelineExecutionException( @@ -73,6 +77,12 @@ namespace XplorePlane.Services node.Order, node.OperatorKey); } + + if (step == total - 1) + { + templateOverlayData = TemplateMatchOverlayRenderer.TrySnapshotOutputForOverlay( + node.OperatorKey, parameters, output); + } } catch (OperationCanceledException) { @@ -97,7 +107,7 @@ namespace XplorePlane.Services if (!current.IsFrozen) current.Freeze(); - return current; + return new PipelineExecutionResult(current, templateOverlayData); } private static BitmapSource ScaleForPreview(BitmapSource source) diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs index 2810d1a..7b4ede6 100644 --- a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -17,7 +17,6 @@ using XplorePlane.Events; using XplorePlane.Models; using XplorePlane.Services; using XplorePlane.Services.MainViewport; -using XplorePlane.Views; using XP.Common.Logging.Interfaces; using Prism.Events; @@ -75,7 +74,6 @@ 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(); @@ -90,18 +88,9 @@ namespace XplorePlane.ViewModels.Cnc { 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; @@ -156,45 +145,6 @@ 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)) @@ -524,11 +474,11 @@ namespace XplorePlane.ViewModels.Cnc try { _logger.Info("[图像链路][CNC] ExecutePreviewAsync:开始执行,节点数={Count}", PipelineNodes.Count); - var result = await _executionService.ExecutePipelineAsync(GetNodesInExecutionScope(), sourceImage, null, token); + var execResult = await _executionService.ExecutePipelineAsync(GetNodesInExecutionScope(), sourceImage, null, token); _logger.Info("[图像链路][CNC] ExecutePreviewAsync:执行完成,推送结果图像"); - _mainViewportService.SetManualImage(result, string.Empty); + _mainViewportService.SetManualImage(execResult.Image, string.Empty); _eventAggregator?.GetEvent() - .Publish(new PipelinePreviewUpdatedPayload(result, StatusMessage)); + .Publish(new PipelinePreviewUpdatedPayload(execResult.Image, StatusMessage)); } catch (OperationCanceledException) { @@ -639,7 +589,6 @@ 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 26bd92e..d29a005 100644 --- a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs @@ -38,9 +38,5 @@ namespace XplorePlane.ViewModels ICommand SaveAsPipelineCommand { get; } ICommand LoadPipelineCommand { get; } - - bool IsTemplateMatchingNodeSelected { get; } - - ICommand OpenTemplateMatchingToolCommand { get; } } } diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index 613a83c..8bbf0c1 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -8,15 +8,18 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Windows; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Media.Imaging; +using System.Windows; +using System.Windows.Controls; +using XplorePlane.Services; +using XplorePlane.ViewModels; +using XP.ImageProcessing.RoiControl.Controls; using XP.Common.Logging.Interfaces; using XplorePlane.Events; using XplorePlane.Models; -using XplorePlane.Services; using XplorePlane.Services.Storage; -using XplorePlane.Views; namespace XplorePlane.ViewModels { @@ -48,7 +51,8 @@ namespace XplorePlane.ViewModels private CancellationTokenSource _executionCts; private CancellationTokenSource _debounceCts; - private readonly DelegateCommand _openTemplateMatchingToolCommand; + private PolygonRoiCanvas _pipelinePreviewCanvas; + private Image _templateMatchOverlayImage; public PipelineEditorViewModel( IImageProcessingService imageProcessingService, @@ -84,7 +88,6 @@ namespace XplorePlane.ViewModels LoadImageCommand = new DelegateCommand(LoadImage); MoveNodeUpCommand = new DelegateCommand(MoveNodeUp); MoveNodeDownCommand = new DelegateCommand(MoveNodeDown); - _openTemplateMatchingToolCommand = new DelegateCommand(OpenTemplateMatchingTool, CanOpenTemplateMatchingTool); _eventAggregator.GetEvent() .Subscribe(OnManualImageLoaded); @@ -104,16 +107,10 @@ namespace XplorePlane.ViewModels { 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; @@ -121,6 +118,7 @@ namespace XplorePlane.ViewModels { if (SetProperty(ref _sourceImage, value)) { + RemoveTemplateMatchOverlay(); ExecutePipelineCommand.RaiseCanExecuteChanged(); RaisePropertyChanged(nameof(DisplayImage)); TriggerDebouncedExecution(); @@ -134,7 +132,11 @@ namespace XplorePlane.ViewModels set { if (SetProperty(ref _previewImage, value)) + { + if (value == null) + RemoveTemplateMatchOverlay(); RaisePropertyChanged(nameof(DisplayImage)); + } } } @@ -216,7 +218,6 @@ namespace XplorePlane.ViewModels public DelegateCommand MoveNodeUpCommand { get; } public DelegateCommand MoveNodeDownCommand { get; } - public ICommand OpenTemplateMatchingToolCommand => _openTemplateMatchingToolCommand; ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand; ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand; @@ -428,43 +429,6 @@ 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) @@ -495,13 +459,17 @@ namespace XplorePlane.ViewModels var progress = new Progress(p => SetInfoStatus($"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})")); - var result = await _executionService.ExecutePipelineAsync( + RemoveTemplateMatchOverlay(); + + var execResult = await _executionService.ExecutePipelineAsync( executionNodes, SourceImage, progress, token); - PreviewImage = result; + PreviewImage = execResult.Image; + ApplyTemplateMatchOverlay(execResult); + SetInfoStatus(BuildExecutionCompletedMessage(executionNodes.Count)); _logger.Info("[图像链路] ExecutePipelineAsync:执行完成,准备发布 PipelinePreviewUpdatedEvent"); - PublishPipelinePreviewUpdated(result, StatusMessage); + PublishPipelinePreviewUpdated(execResult.Image, StatusMessage); } catch (OperationCanceledException) { @@ -551,6 +519,7 @@ namespace XplorePlane.ViewModels { IsStatusError = true; StatusMessage = message; + RemoveTemplateMatchOverlay(); PublishPipelinePreviewUpdated(PreviewImage ?? SourceImage, message); } @@ -627,6 +596,61 @@ namespace XplorePlane.ViewModels .Publish(new PipelinePreviewUpdatedPayload(bitmap, statusMessage)); } + /// 由流水线编辑器窗口在 Loaded 时挂上,用于在底图上叠加模板匹配线框(与 BGA 工具分层一致)。 + public void AttachPreviewCanvas(PolygonRoiCanvas canvas) + { + _pipelinePreviewCanvas = canvas; + } + + private void ApplyTemplateMatchOverlay(PipelineExecutionResult execResult) + { + RemoveTemplateMatchOverlay(); + if (_pipelinePreviewCanvas == null || execResult.TemplateMatchOverlayData == null) + return; + + var overlay = TemplateMatchOverlayRenderer.CreateTransparentOverlay( + execResult.Image.PixelWidth, + execResult.Image.PixelHeight, + execResult.TemplateMatchOverlayData); + if (overlay == null) + return; + + ShowTemplateMatchOverlay(overlay); + } + + private void ShowTemplateMatchOverlay(BitmapSource overlayBmp) + { + if (_pipelinePreviewCanvas == null) + return; + + RemoveTemplateMatchOverlay(); + + _templateMatchOverlayImage = new Image + { + Source = overlayBmp, + IsHitTestVisible = false, + Stretch = Stretch.Fill + }; + _templateMatchOverlayImage.SetBinding(FrameworkElement.WidthProperty, + new System.Windows.Data.Binding("CanvasWidth") { Source = _pipelinePreviewCanvas }); + _templateMatchOverlayImage.SetBinding(FrameworkElement.HeightProperty, + new System.Windows.Data.Binding("CanvasHeight") { Source = _pipelinePreviewCanvas }); + + if (_pipelinePreviewCanvas.FindName("mainCanvas") is Canvas mainCanvas) + { + int insertIndex = Math.Min(1, mainCanvas.Children.Count); + mainCanvas.Children.Insert(insertIndex, _templateMatchOverlayImage); + } + } + + private void RemoveTemplateMatchOverlay() + { + if (_templateMatchOverlayImage == null || _pipelinePreviewCanvas == null) + return; + _pipelinePreviewCanvas.RemoveFromCanvas(_templateMatchOverlayImage); + _templateMatchOverlayImage = null; + } + private void OnManualImageLoaded(ManualImageLoadedPayload payload) { if (payload?.Image == null) return; @@ -669,6 +693,7 @@ namespace XplorePlane.ViewModels SelectedNode = null; ExecutionEndNode = null; PipelineName = "新建流水线"; + RemoveTemplateMatchOverlay(); PreviewImage = null; _currentFilePath = null; PipelineFileDisplayName = DefaultPipelineFileDisplayName; diff --git a/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs deleted file mode 100644 index 0c460b6..0000000 --- a/XplorePlane/ViewModels/ImageProcessing/TemplateMatchingToolViewModel.cs +++ /dev/null @@ -1,538 +0,0 @@ -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 fd37fed..fbf9776 100644 --- a/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml +++ b/XplorePlane/Views/ImageProcessing/PipelineEditorView.xaml @@ -301,41 +301,14 @@ HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -