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">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs b/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs
deleted file mode 100644
index 2c7c68e..0000000
--- a/XplorePlane/Views/ImageProcessing/TemplateMatchingToolWindow.xaml.cs
+++ /dev/null
@@ -1,558 +0,0 @@
-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;
- }
- }
-}