// ============================================================================ // 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; } } }