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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs b/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs
new file mode 100644
index 0000000..2c7c68e
--- /dev/null
+++ b/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs
@@ -0,0 +1,558 @@
+using System;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+using XP.Common.Logging.Interfaces;
+using XplorePlane.Services;
+using XplorePlane.ViewModels;
+
+namespace XplorePlane.Views
+{
+ public partial class TemplateMatchingToolWindow : Window
+ {
+ private const double HandleHitTolerance = 10;
+
+ private enum DragKind
+ {
+ None,
+ CreateRect,
+ MoveRect,
+ ResizeRect
+ }
+
+ private enum CornerKind
+ {
+ None,
+ Nw,
+ Ne,
+ Se,
+ Sw
+ }
+
+ private DragKind _dragKind;
+ private CornerKind _resizeCorner;
+ /// 拖拽全程在 Canvas DIP 空间计算草稿(与气泡测量 ROI 一致),仅在 MouseUp 时换算为像素。
+ private double _dipDragStartX;
+ private double _dipDragStartY;
+ private Point _movePointerStartDip;
+ private Rect _moveRoiStartDip;
+ private Rect _resizeRoiStartDip;
+ private Rect _lastDraftDipRect;
+ private bool _manipulateSearchRoi;
+
+ private TemplateMatchingToolViewModel? ViewModel => DataContext as TemplateMatchingToolViewModel;
+
+ private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
+
+ public TemplateMatchingToolWindow(
+ PipelineNodeViewModel node,
+ BitmapSource? sourceImage,
+ IImageProcessingService imageProcessingService,
+ ILoggerService logger,
+ string? recipeDirectory = null)
+ {
+ InitializeComponent();
+ DataContext = new TemplateMatchingToolViewModel(node, sourceImage, imageProcessingService, logger, recipeDirectory);
+ Closed += TemplateMatchingToolWindow_OnClosed;
+ }
+
+ private void TemplateMatchingToolWindow_OnClosed(object? sender, EventArgs e)
+ {
+ if (DataContext is TemplateMatchingToolViewModel vm)
+ vm.PropertyChanged -= ViewModelOnPropertyChanged;
+ RoiViewportHost.SizeChanged -= RoiViewportOnSizeChanged;
+ }
+
+ private void TemplateMatchingToolWindow_OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is TemplateMatchingToolViewModel vm)
+ {
+ vm.PropertyChanged -= ViewModelOnPropertyChanged;
+ vm.PropertyChanged += ViewModelOnPropertyChanged;
+ }
+
+ RoiViewportHost.SizeChanged -= RoiViewportOnSizeChanged;
+ RoiViewportHost.SizeChanged += RoiViewportOnSizeChanged;
+ RedrawRoiCanvas();
+ Dispatcher.BeginInvoke(new Action(RedrawRoiCanvas), DispatcherPriority.Loaded);
+ }
+
+ private void RoiViewportOnSizeChanged(object sender, SizeChangedEventArgs e) =>
+ RedrawRoiCanvas();
+
+ private void ViewModelOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(TemplateMatchingToolViewModel.CropXText)
+ or nameof(TemplateMatchingToolViewModel.CropYText)
+ or nameof(TemplateMatchingToolViewModel.CropWidthText)
+ or nameof(TemplateMatchingToolViewModel.CropHeightText)
+ or nameof(TemplateMatchingToolViewModel.SearchRegionDisplay)
+ or nameof(TemplateMatchingToolViewModel.RoiOverlayRevision)
+ or nameof(TemplateMatchingToolViewModel.EditSearchRoiActive)
+ or nameof(TemplateMatchingToolViewModel.EditTemplateRoiActive))
+ RedrawRoiCanvas();
+ }
+
+ private void RedrawRoiCanvas()
+ {
+ var vm = ViewModel;
+ CropOverlayCanvas.Children.Clear();
+
+ if (vm?.SourceImage == null)
+ return;
+
+ double aw = CropOverlayCanvas.ActualWidth;
+ double ah = CropOverlayCanvas.ActualHeight;
+ if (aw < 0.5 || ah < 0.5)
+ return;
+
+ var bmp = vm.SourceImage;
+ if (!vm.TryGetSearchRectangle(out int sx, out int sy, out int sw, out int sh))
+ return;
+
+ if (!vm.TryGetCropRectangle(out int tx, out int ty, out int tw, out int th))
+ return;
+
+ PixelRectToDisplay(bmp, aw, ah, sx, sy, sw, sh, out double sL, out double sT, out double sW, out double sH);
+ AddRoiVisual(CropOverlayCanvas, sL, sT, sW, sH, Color.FromRgb(0, 188, 212), 0.12, "搜索框");
+
+ PixelRectToDisplay(bmp, aw, ah, tx, ty, tw, th, out double tL, out double tT, out double tW, out double tH);
+ AddRoiVisual(CropOverlayCanvas, tL, tT, tW, tH, Color.FromRgb(118, 255, 3), 0.14, "模板框");
+
+ bool searchActive = vm.EditSearchRoiActive;
+ double hl = searchActive ? sL : tL;
+ double ht = searchActive ? sT : tT;
+ double hw = searchActive ? sW : tW;
+ double hh = searchActive ? sH : tH;
+ AddCornerHandles(CropOverlayCanvas, hl, ht, hw, hh);
+ }
+
+ private static void AddRoiVisual(Canvas canvas, double l, double t, double w, double h, Color stroke, double fillAlpha, string label)
+ {
+ var rect = new Rectangle
+ {
+ Width = Math.Max(1, w),
+ Height = Math.Max(1, h),
+ Stroke = new SolidColorBrush(stroke),
+ StrokeThickness = 2,
+ Fill = new SolidColorBrush(Color.FromArgb((byte)(255 * fillAlpha), stroke.R, stroke.G, stroke.B)),
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(rect, l);
+ Canvas.SetTop(rect, t);
+ canvas.Children.Add(rect);
+
+ var tb = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(stroke),
+ FontSize = 11,
+ FontWeight = FontWeights.SemiBold,
+ Background = new SolidColorBrush(Color.FromArgb(242, 255, 255, 255)),
+ Padding = new Thickness(3, 1, 3, 1),
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(tb, l + 3);
+ Canvas.SetTop(tb, Math.Max(0, t - 18));
+ canvas.Children.Add(tb);
+ }
+
+ private static void AddCornerHandles(Canvas canvas, double l, double t, double w, double h)
+ {
+ const double hs = 8;
+ var fill = new SolidColorBrush(Color.FromRgb(255, 235, 59));
+ var stroke = new SolidColorBrush(Colors.Black);
+
+ void AddH(double cx, double cy)
+ {
+ var r = new Rectangle
+ {
+ Width = hs,
+ Height = hs,
+ Fill = fill,
+ Stroke = stroke,
+ StrokeThickness = 1,
+ IsHitTestVisible = false
+ };
+ Canvas.SetLeft(r, cx - hs / 2);
+ Canvas.SetTop(r, cy - hs / 2);
+ canvas.Children.Add(r);
+ }
+
+ AddH(l, t);
+ AddH(l + w, t);
+ AddH(l + w, t + h);
+ AddH(l, t + h);
+ }
+
+ private void CropOverlay_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ var vm = ViewModel;
+ if (vm?.SourceImage == null)
+ return;
+
+ var pos = e.GetPosition(CropOverlayCanvas);
+ var bmp = vm.SourceImage;
+ double aw = CropOverlayCanvas.ActualWidth;
+ double ah = CropOverlayCanvas.ActualHeight;
+
+ _manipulateSearchRoi = vm.EditSearchRoiActive;
+ vm.TryGetSearchRectangle(out int sx, out int sy, out int sw, out int sh);
+ vm.TryGetCropRectangle(out int tx, out int ty, out int tw, out int th);
+
+ PixelRectToDisplay(bmp, aw, ah, sx, sy, sw, sh, out double sL, out double sT, out double sW, out double sH);
+ PixelRectToDisplay(bmp, aw, ah, tx, ty, tw, th, out double tL, out double tT, out double tW, out double tH);
+
+ // 命中与叠层均使用 Canvas 坐标 + 与 PixelRectToDisplay 相同的 aw/ah(与气泡测量「整图在 Canvas 上拖 ROI」一致)
+ if (_manipulateSearchRoi)
+ {
+ if (TryHitCorner(pos.X, pos.Y, sL, sT, sW, sH, out var c))
+ {
+ _dragKind = DragKind.ResizeRect;
+ _resizeCorner = c;
+ }
+ else if (IsInsideDisplayRect(pos.X, pos.Y, sL, sT, sW, sH))
+ {
+ _dragKind = DragKind.MoveRect;
+ }
+ else
+ {
+ _dragKind = DragKind.CreateRect;
+ }
+ }
+ else
+ {
+ if (TryHitCorner(pos.X, pos.Y, tL, tT, tW, tH, out var c))
+ {
+ _dragKind = DragKind.ResizeRect;
+ _resizeCorner = c;
+ }
+ else if (IsInsideDisplayRect(pos.X, pos.Y, tL, tT, tW, tH))
+ {
+ _dragKind = DragKind.MoveRect;
+ }
+ else
+ {
+ _dragKind = DragKind.CreateRect;
+ }
+ }
+
+ _dipDragStartX = pos.X;
+ _dipDragStartY = pos.Y;
+ _movePointerStartDip = pos;
+ if (_dragKind == DragKind.MoveRect)
+ {
+ double ml, mt, mw, mh;
+ if (_manipulateSearchRoi)
+ PixelRectToDisplay(bmp, aw, ah, sx, sy, sw, sh, out ml, out mt, out mw, out mh);
+ else
+ PixelRectToDisplay(bmp, aw, ah, tx, ty, tw, th, out ml, out mt, out mw, out mh);
+ _moveRoiStartDip = new Rect(ml, mt, mw, mh);
+ }
+
+ if (_dragKind == DragKind.ResizeRect)
+ {
+ double rl, rt, rw, rh;
+ if (_manipulateSearchRoi)
+ PixelRectToDisplay(bmp, aw, ah, sx, sy, sw, sh, out rl, out rt, out rw, out rh);
+ else
+ PixelRectToDisplay(bmp, aw, ah, tx, ty, tw, th, out rl, out rt, out rw, out rh);
+ _resizeRoiStartDip = new Rect(rl, rt, rw, rh);
+ }
+
+ CropOverlayCanvas.CaptureMouse();
+ ApplyDraftFromDragDip(vm, pos);
+ e.Handled = true;
+ }
+
+ private void CropOverlay_OnMouseMove(object sender, MouseEventArgs e)
+ {
+ if (_dragKind == DragKind.None || ViewModel?.SourceImage == null)
+ return;
+
+ ApplyDraftFromDragDip(ViewModel, e.GetPosition(CropOverlayCanvas));
+ e.Handled = true;
+ }
+
+ private void CropOverlay_OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (_dragKind == DragKind.None)
+ return;
+
+ var savedSearch = _manipulateSearchRoi;
+ var vm = ViewModel;
+
+ if (vm?.SourceImage != null)
+ ApplyDraftFromDragDip(vm, e.GetPosition(CropOverlayCanvas));
+
+ var draft = _lastDraftDipRect;
+
+ _dragKind = DragKind.None;
+ _resizeCorner = CornerKind.None;
+ CropOverlayCanvas.ReleaseMouseCapture();
+
+ if (vm?.SourceImage != null && draft.Width > 0.5 && draft.Height > 0.5
+ && DisplayDipRectToPixelRect(vm.SourceImage, CropOverlayCanvas.ActualWidth, CropOverlayCanvas.ActualHeight, draft, out int ox, out int oy, out int ow, out int oh))
+ {
+ if (savedSearch)
+ vm.SetSearchRoiPixels(ox, oy, ow, oh);
+ else
+ vm.SetCropPixels(ox, oy, ow, oh);
+ }
+
+ RedrawRoiCanvas();
+ e.Handled = true;
+ }
+
+ private void CropOverlay_OnLostMouseCapture(object sender, MouseEventArgs e)
+ {
+ if (_dragKind == DragKind.None)
+ return;
+
+ _dragKind = DragKind.None;
+ _resizeCorner = CornerKind.None;
+ RedrawRoiCanvas();
+ }
+
+ private void ApplyDraftFromDragDip(TemplateMatchingToolViewModel vm, Point curDip)
+ {
+ if (!TryComputeDraftDip(vm, curDip, out Rect draft))
+ return;
+
+ _lastDraftDipRect = draft;
+ if (_manipulateSearchRoi)
+ RedrawDraftSearch(vm, draft);
+ else
+ RedrawDraftTemplate(vm, draft);
+ }
+
+ private bool TryComputeDraftDip(TemplateMatchingToolViewModel vm, Point cur, out Rect draft)
+ {
+ draft = default;
+ var bmp = vm.SourceImage!;
+ double aw = CropOverlayCanvas.ActualWidth;
+ double ah = CropOverlayCanvas.ActualHeight;
+ if (!TryGetUniformLetterboxInDip(bmp, aw, ah, out double ox, out double oy, out double dw, out double dh, out _, out _, out _))
+ return false;
+
+ var content = new Rect(ox, oy, dw, dh);
+
+ switch (_dragKind)
+ {
+ case DragKind.CreateRect:
+ {
+ var raw = NormalizeDipRect(new Point(_dipDragStartX, _dipDragStartY), cur);
+ draft = IntersectRects(raw, content);
+ if (draft.IsEmpty)
+ draft = new Rect(cur.X, cur.Y, 1, 1);
+ return true;
+ }
+ case DragKind.MoveRect:
+ {
+ var delta = cur - _movePointerStartDip;
+ draft = new Rect(
+ _moveRoiStartDip.X + delta.X,
+ _moveRoiStartDip.Y + delta.Y,
+ _moveRoiStartDip.Width,
+ _moveRoiStartDip.Height);
+ draft = ClampRoiToContent(draft, content);
+ return true;
+ }
+ case DragKind.ResizeRect:
+ return TryResizeRectDip(_resizeRoiStartDip, _resizeCorner, cur, content, out draft);
+ default:
+ return false;
+ }
+ }
+
+ private void RedrawDraftSearch(TemplateMatchingToolViewModel vm, Rect searchDip)
+ {
+ CropOverlayCanvas.Children.Clear();
+ if (vm.SourceImage == null) return;
+ var bmp = vm.SourceImage;
+ double aw = CropOverlayCanvas.ActualWidth;
+ double ah = CropOverlayCanvas.ActualHeight;
+ vm.TryGetCropRectangle(out int tx, out int ty, out int tw, out int th);
+ PixelRectToDisplay(bmp, aw, ah, tx, ty, tw, th, out double tL, out double tT, out double tW, out double tH);
+ AddRoiVisual(CropOverlayCanvas, tL, tT, tW, tH, Color.FromRgb(118, 255, 3), 0.14, "模板框");
+
+ AddRoiVisual(CropOverlayCanvas, searchDip.X, searchDip.Y, searchDip.Width, searchDip.Height, Color.FromRgb(0, 188, 212), 0.12, "搜索框");
+ AddCornerHandles(CropOverlayCanvas, searchDip.X, searchDip.Y, searchDip.Width, searchDip.Height);
+ }
+
+ private void RedrawDraftTemplate(TemplateMatchingToolViewModel vm, Rect templateDip)
+ {
+ CropOverlayCanvas.Children.Clear();
+ if (vm.SourceImage == null) return;
+ var bmp = vm.SourceImage;
+ double aw = CropOverlayCanvas.ActualWidth;
+ double ah = CropOverlayCanvas.ActualHeight;
+ vm.TryGetSearchRectangle(out int sx, out int sy, out int sw, out int sh);
+ PixelRectToDisplay(bmp, aw, ah, sx, sy, sw, sh, out double sL, out double sT, out double sW, out double sH);
+ AddRoiVisual(CropOverlayCanvas, sL, sT, sW, sH, Color.FromRgb(0, 188, 212), 0.12, "搜索框");
+
+ AddRoiVisual(CropOverlayCanvas, templateDip.X, templateDip.Y, templateDip.Width, templateDip.Height, Color.FromRgb(118, 255, 3), 0.14, "模板框");
+ AddCornerHandles(CropOverlayCanvas, templateDip.X, templateDip.Y, templateDip.Width, templateDip.Height);
+ }
+
+ private static Rect NormalizeDipRect(Point a, Point b) =>
+ new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Max(1e-6, Math.Abs(b.X - a.X)), Math.Max(1e-6, Math.Abs(b.Y - a.Y)));
+
+ private static Rect IntersectRects(Rect a, Rect b)
+ {
+ double x1 = Math.Max(a.Left, b.Left);
+ double y1 = Math.Max(a.Top, b.Top);
+ double x2 = Math.Min(a.Right, b.Right);
+ double y2 = Math.Min(a.Bottom, b.Bottom);
+ if (x2 <= x1 || y2 <= y1)
+ return Rect.Empty;
+ return new Rect(x1, y1, x2 - x1, y2 - y1);
+ }
+
+ private static Rect ClampRoiToContent(Rect r, Rect content)
+ {
+ double w = Math.Max(1, r.Width);
+ double h = Math.Max(1, r.Height);
+ double x = Math.Clamp(r.X, content.Left, content.Left + Math.Max(0, content.Width - w));
+ double y = Math.Clamp(r.Y, content.Top, content.Top + Math.Max(0, content.Height - h));
+ return new Rect(x, y, w, h);
+ }
+
+ private static bool TryResizeRectDip(Rect r0, CornerKind corner, Point cur, Rect content, out Rect draft)
+ {
+ draft = r0;
+ Rect n;
+ switch (corner)
+ {
+ case CornerKind.Se:
+ n = NormalizeDipRect(new Point(r0.Left, r0.Top), cur);
+ break;
+ case CornerKind.Nw:
+ n = NormalizeDipRect(new Point(r0.Right, r0.Bottom), cur);
+ break;
+ case CornerKind.Ne:
+ n = NormalizeDipRect(new Point(r0.Left, r0.Bottom), cur);
+ break;
+ case CornerKind.Sw:
+ n = NormalizeDipRect(new Point(r0.Right, r0.Top), cur);
+ break;
+ default:
+ return false;
+ }
+
+ var inter = IntersectRects(n, content);
+ draft = inter.IsEmpty ? ClampRoiToContent(n, content) : ClampRoiToContent(inter, content);
+ return !draft.IsEmpty;
+ }
+
+ private static bool DisplayDipRectToPixelRect(BitmapSource bmp, double aw, double ah, Rect dip, out int x, out int y, out int w, out int h)
+ {
+ x = y = w = h = 0;
+ if (!TryGetUniformLetterboxInDip(bmp, aw, ah, out double ox, out double oy, out _, out _, out double dppX, out double dppY, out double fit))
+ return false;
+
+ int iw = bmp.PixelWidth;
+ int ih = bmp.PixelHeight;
+ double cellW = dppX * fit;
+ double cellH = dppY * fit;
+ double fx0 = (dip.Left - ox) / cellW;
+ double fy0 = (dip.Top - oy) / cellH;
+ double fx1 = (dip.Right - ox) / cellW;
+ double fy1 = (dip.Bottom - oy) / cellH;
+ int px0 = Math.Clamp((int)Math.Floor(fx0), 0, Math.Max(0, iw - 1));
+ int py0 = Math.Clamp((int)Math.Floor(fy0), 0, Math.Max(0, ih - 1));
+ int px1 = Math.Clamp((int)Math.Ceiling(fx1) - 1, 0, Math.Max(0, iw - 1));
+ int py1 = Math.Clamp((int)Math.Ceiling(fy1) - 1, 0, Math.Max(0, ih - 1));
+ if (px1 < px0)
+ (px0, px1) = (px1, px0);
+ if (py1 < py0)
+ (py0, py1) = (py1, py0);
+ x = px0;
+ y = py0;
+ w = Math.Max(1, px1 - px0 + 1);
+ h = Math.Max(1, py1 - py0 + 1);
+ return true;
+ }
+
+ private static bool TryHitCorner(double px, double py, double l, double t, double w, double h, out CornerKind corner)
+ {
+ corner = CornerKind.None;
+ double tol = HandleHitTolerance;
+ bool near(double a, double b) => Math.Abs(a - b) <= tol;
+
+ if (near(px, l) && near(py, t)) { corner = CornerKind.Nw; return true; }
+ if (near(px, l + w) && near(py, t)) { corner = CornerKind.Ne; return true; }
+ if (near(px, l + w) && near(py, t + h)) { corner = CornerKind.Se; return true; }
+ if (near(px, l) && near(py, t + h)) { corner = CornerKind.Sw; return true; }
+ return false;
+ }
+
+ private static bool IsInsideDisplayRect(double px, double py, double l, double t, double w, double h) =>
+ px >= l && px <= l + w && py >= t && py <= t + h;
+
+ ///
+ /// 与 WPF + 一致:用位图源 DPI 得到逻辑尺寸(DIP),再算 letterbox。
+ ///
+ private static bool TryGetUniformLetterboxInDip(
+ BitmapSource bmp,
+ double aw,
+ double ah,
+ out double ox,
+ out double oy,
+ out double dispW,
+ out double dispH,
+ out double dipPerPixelX,
+ out double dipPerPixelY,
+ out double fitScale)
+ {
+ int iw = bmp.PixelWidth;
+ int ih = bmp.PixelHeight;
+ double dpiX = bmp.DpiX > 0 ? bmp.DpiX : 96.0;
+ double dpiY = bmp.DpiY > 0 ? bmp.DpiY : 96.0;
+ dipPerPixelX = 96.0 / dpiX;
+ dipPerPixelY = 96.0 / dpiY;
+ double natW = iw * dipPerPixelX;
+ double natH = ih * dipPerPixelY;
+ ox = oy = dispW = dispH = fitScale = 0;
+ if (aw < 0.5 || ah < 0.5 || natW <= 0 || natH <= 0)
+ return false;
+
+ fitScale = Math.Min(aw / natW, ah / natH);
+ dispW = natW * fitScale;
+ dispH = natH * fitScale;
+ ox = (aw - dispW) * 0.5;
+ oy = (ah - dispH) * 0.5;
+ return true;
+ }
+
+ private static void PixelRectToDisplay(
+ BitmapSource bmp,
+ double aw,
+ double ah,
+ int rx,
+ int ry,
+ int rw,
+ int rh,
+ out double left,
+ out double top,
+ out double width,
+ out double height)
+ {
+ if (!TryGetUniformLetterboxInDip(bmp, aw, ah, out double ox, out double oy, out _, out _, out double dppX, out double dppY, out double fit))
+ {
+ left = top = width = height = 0;
+ return;
+ }
+
+ left = ox + rx * dppX * fit;
+ top = oy + ry * dppY * fit;
+ width = rw * dppX * fit;
+ height = rh * dppY * fit;
+ }
+ }
+}