基于灰度的模板匹配算子开发及集成

This commit is contained in:
李伟
2026-05-12 14:47:27 +08:00
parent 9f604d4e2f
commit f5f449b6fc
17 changed files with 2305 additions and 8 deletions
+153
View File
@@ -780,6 +780,159 @@ namespace XP.Common.Resources {
}
}
/// <summary>
/// 查找类似 模板匹配 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_Name {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_Name", resourceCulture);
}
}
/// <summary>
/// 查找类似 在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_Description {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_Description", resourceCulture);
}
}
/// <summary>
/// 查找类似 模板文件路径 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_TemplatePath {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_TemplatePath", resourceCulture);
}
}
/// <summary>
/// 查找类似 磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度) 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_TemplatePath_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_TemplatePath_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 匹配方法 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_MatchMethod {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_MatchMethod", resourceCulture);
}
}
/// <summary>
/// 查找类似 OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似) 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_MatchMethod_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_MatchMethod_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 匹配阈值 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_MatchThreshold {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_MatchThreshold", resourceCulture);
}
}
/// <summary>
/// 查找类似 相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3) 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_MatchThreshold_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_MatchThreshold_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 绘制匹配矩形 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_DrawMatch {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_DrawMatch", resourceCulture);
}
}
/// <summary>
/// 查找类似 匹配通过阈值时在输出图上用白框标出模板区域 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_DrawMatch_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_DrawMatch_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 矩形线宽 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_RectThickness {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_RectThickness", resourceCulture);
}
}
/// <summary>
/// 查找类似 匹配框线宽(像素) 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_RectThickness_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_RectThickness_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 Search region X 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_SearchRegionX {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionX", resourceCulture);
}
}
/// <summary>
/// 查找类似 Search region Y 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_SearchRegionY {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionY", resourceCulture);
}
}
/// <summary>
/// 查找类似 Search region width 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_SearchRegionWidth {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionWidth", resourceCulture);
}
}
/// <summary>
/// 查找类似 Search region height 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_SearchRegionHeight {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegionHeight", resourceCulture);
}
}
/// <summary>
/// 查找类似 Rectangular search window in source pixels. 的本地化字符串。
/// </summary>
public static string TemplateMatchingProcessor_SearchRegion_Desc {
get {
return ResourceManager.GetString("TemplateMatchingProcessor_SearchRegion_Desc", resourceCulture);
}
}
/// <summary>
/// 查找类似 自动对比度 的本地化字符串。
/// </summary>
+51
View File
@@ -489,6 +489,57 @@
<data name="ContourProcessor_Thickness_Desc" xml:space="preserve">
<value>Thickness of contour lines</value>
</data>
<data name="TemplateMatchingProcessor_Name" xml:space="preserve">
<value>Template Matching</value>
</data>
<data name="TemplateMatchingProcessor_Description" xml:space="preserve">
<value>Find the best match for a template in the grayscale image; optionally draw the match rectangle</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath" xml:space="preserve">
<value>Template file path</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath_Desc" xml:space="preserve">
<value>Path to template image (bmp/png/jpg, etc.); color images are converted to grayscale</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod" xml:space="preserve">
<value>Match method</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod_Desc" xml:space="preserve">
<value>OpenCV template method; higher is better for CcoeffNormed/CcorrNormed; lower is better for SqdiffNormed (0 = identical)</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold" xml:space="preserve">
<value>Match threshold</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold_Desc" xml:space="preserve">
<value>Correlation methods: match if score ≥ threshold. Sqdiff/SqdiffNormed: match if score ≤ threshold (try 0.10.3 for Normed)</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch" xml:space="preserve">
<value>Draw match rectangle</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch_Desc" xml:space="preserve">
<value>When match passes threshold, draw a white rectangle on the output</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness" xml:space="preserve">
<value>Rectangle thickness</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness_Desc" xml:space="preserve">
<value>Line width of the match box in pixels</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionX" xml:space="preserve">
<value>Search region X</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionY" xml:space="preserve">
<value>Search region Y</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionWidth" xml:space="preserve">
<value>Search region width</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionHeight" xml:space="preserve">
<value>Search region height</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegion_Desc" xml:space="preserve">
<value>Rectangular search window in source pixels. Width or height 0 means full image. Written by the template matching tool.</value>
</data>
<data name="DivisionProcessor_Name" xml:space="preserve">
<value>Division Operation</value>
</data>
+51
View File
@@ -489,6 +489,57 @@
<data name="ContourProcessor_Thickness_Desc" xml:space="preserve">
<value>绘制轮廓的线条粗细</value>
</data>
<data name="TemplateMatchingProcessor_Name" xml:space="preserve">
<value>模板匹配</value>
</data>
<data name="TemplateMatchingProcessor_Description" xml:space="preserve">
<value>在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath" xml:space="preserve">
<value>模板文件路径</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath_Desc" xml:space="preserve">
<value>磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度)</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod" xml:space="preserve">
<value>匹配方法</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod_Desc" xml:space="preserve">
<value>OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似)</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold" xml:space="preserve">
<value>匹配阈值</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold_Desc" xml:space="preserve">
<value>相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch" xml:space="preserve">
<value>绘制匹配矩形</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch_Desc" xml:space="preserve">
<value>匹配通过阈值时在输出图上用白框标出模板区域</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness" xml:space="preserve">
<value>矩形线宽</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness_Desc" xml:space="preserve">
<value>匹配框线宽(像素)</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionX" xml:space="preserve">
<value>Search region X</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionY" xml:space="preserve">
<value>Search region Y</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionWidth" xml:space="preserve">
<value>Search region width</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionHeight" xml:space="preserve">
<value>Search region height</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegion_Desc" xml:space="preserve">
<value>Rectangular search window in source pixels. Width or height 0 means full image. Written by the template matching tool.</value>
</data>
<data name="DivisionProcessor_Name" xml:space="preserve">
<value>除法运算</value>
</data>
+51
View File
@@ -489,6 +489,57 @@
<data name="ContourProcessor_Thickness_Desc" xml:space="preserve">
<value>绘制轮廓的线条粗细</value>
</data>
<data name="TemplateMatchingProcessor_Name" xml:space="preserve">
<value>模板匹配</value>
</data>
<data name="TemplateMatchingProcessor_Description" xml:space="preserve">
<value>在整幅灰度图中搜索模板图像的最佳位置,可选绘制匹配框</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath" xml:space="preserve">
<value>模板文件路径</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath_Desc" xml:space="preserve">
<value>磁盘上的模板图像路径(支持 bmp/png/jpg 等,彩色将转为灰度)</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod" xml:space="preserve">
<value>匹配方法</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod_Desc" xml:space="preserve">
<value>OpenCV 模板匹配类型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似)</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold" xml:space="preserve">
<value>匹配阈值</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold_Desc" xml:space="preserve">
<value>相关类方法:得分需≥该值判为匹配;Sqdiff/SqdiffNormed:得分需≤该值(建议 Normed 时 0.1~0.3</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch" xml:space="preserve">
<value>绘制匹配矩形</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch_Desc" xml:space="preserve">
<value>匹配通过阈值时在输出图上用白框标出模板区域</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness" xml:space="preserve">
<value>矩形线宽</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness_Desc" xml:space="preserve">
<value>匹配框线宽(像素)</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionX" xml:space="preserve">
<value>搜索区域 X</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionY" xml:space="preserve">
<value>搜索区域 Y</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionWidth" xml:space="preserve">
<value>搜索区域宽度</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionHeight" xml:space="preserve">
<value>搜索区域高度</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegion_Desc" xml:space="preserve">
<value>在源图上的矩形搜索范围(像素)。宽或高为 0 时表示整幅图。由模板匹配工具写入。</value>
</data>
<data name="DivisionProcessor_Name" xml:space="preserve">
<value>除法运算</value>
</data>
+51
View File
@@ -489,6 +489,57 @@
<data name="ContourProcessor_Thickness_Desc" xml:space="preserve">
<value>绘制轮廓的线条粗细</value>
</data>
<data name="TemplateMatchingProcessor_Name" xml:space="preserve">
<value>模板匹配</value>
</data>
<data name="TemplateMatchingProcessor_Description" xml:space="preserve">
<value>在整幅灰階圖中搜尋模板影像的最佳位置,可選繪製匹配框</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath" xml:space="preserve">
<value>模板檔案路徑</value>
</data>
<data name="TemplateMatchingProcessor_TemplatePath_Desc" xml:space="preserve">
<value>磁碟上的模板影像路徑(支援 bmp/png/jpg 等,彩色將轉為灰階)</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod" xml:space="preserve">
<value>匹配方法</value>
</data>
<data name="TemplateMatchingProcessor_MatchMethod_Desc" xml:space="preserve">
<value>OpenCV 模板匹配類型;CcoeffNormed/CcorrNormed 等越大越好,SqdiffNormed 越小越好(越接近 0 越相似)</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold" xml:space="preserve">
<value>匹配閾值</value>
</data>
<data name="TemplateMatchingProcessor_MatchThreshold_Desc" xml:space="preserve">
<value>相關類方法:得分需≥該值判為匹配;Sqdiff/SqdiffNormed:得分需≤該值(建議 Normed 時 0.1~0.3</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch" xml:space="preserve">
<value>繪製匹配矩形</value>
</data>
<data name="TemplateMatchingProcessor_DrawMatch_Desc" xml:space="preserve">
<value>匹配通過閾值時在輸出圖上以白框標出模板區域</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness" xml:space="preserve">
<value>矩形線寬</value>
</data>
<data name="TemplateMatchingProcessor_RectThickness_Desc" xml:space="preserve">
<value>匹配框線寬(像素)</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionX" xml:space="preserve">
<value>搜尋區域 X</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionY" xml:space="preserve">
<value>搜尋區域 Y</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionWidth" xml:space="preserve">
<value>搜尋區域寬度</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegionHeight" xml:space="preserve">
<value>搜尋區域高度</value>
</data>
<data name="TemplateMatchingProcessor_SearchRegion_Desc" xml:space="preserve">
<value>在源圖上的矩形搜尋範圍(像素)。寬或高為 0 表示整幅圖。由模板匹配工具寫入。</value>
</data>
<data name="DivisionProcessor_Name" xml:space="preserve">
<value>除法运算</value>
</data>
@@ -0,0 +1,282 @@
// ============================================================================
// Copyright © 2026 Hexagon Technology Center GmbH. All Rights Reserved.
// 文件名: TemplateMatchingProcessor.cs
// 描述: 灰度模板匹配算子,在整幅图像中搜索与模板最相似的位置
// 功能:
// - 从磁盘加载模板(灰度或彩色图自动转灰度)
// - OpenCV MatchTemplate + MinMaxLoc 求最佳匹配
// - 可选在输出图上绘制匹配矩形
// - 将匹配结果写入 OutputData(坐标、得分、是否通过阈值)
// 作者: 李伟 wei.lw.li@hexagon.com
// ============================================================================
using System.Drawing;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using XP.ImageProcessing.Core;
using Serilog;
namespace XP.ImageProcessing.Processors;
/// <summary>
/// 模板匹配算子(定位识别)
/// </summary>
public class TemplateMatchingProcessor : ImageProcessorBase
{
private static readonly ILogger _logger = Log.ForContext<TemplateMatchingProcessor>();
private static readonly string[] MatchMethodOptions =
{
"CcoeffNormed",
"SqdiffNormed",
"CcorrNormed",
"Ccoeff",
"Ccorr",
"Sqdiff"
};
public TemplateMatchingProcessor()
{
Name = LocalizationHelper.GetString("TemplateMatchingProcessor_Name");
Description = LocalizationHelper.GetString("TemplateMatchingProcessor_Description");
}
protected override void InitializeParameters()
{
Parameters.Add("TemplatePath", new ProcessorParameter(
"TemplatePath",
LocalizationHelper.GetString("TemplateMatchingProcessor_TemplatePath"),
typeof(string),
string.Empty,
null,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_TemplatePath_Desc")));
Parameters.Add("MatchMethod", new ProcessorParameter(
"MatchMethod",
LocalizationHelper.GetString("TemplateMatchingProcessor_MatchMethod"),
typeof(string),
"CcoeffNormed",
null,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_MatchMethod_Desc"),
MatchMethodOptions));
Parameters.Add("MatchThreshold", new ProcessorParameter(
"MatchThreshold",
LocalizationHelper.GetString("TemplateMatchingProcessor_MatchThreshold"),
typeof(double),
0.75,
0.0,
1.0,
LocalizationHelper.GetString("TemplateMatchingProcessor_MatchThreshold_Desc")));
Parameters.Add("DrawRectangle", new ProcessorParameter(
"DrawRectangle",
LocalizationHelper.GetString("TemplateMatchingProcessor_DrawMatch"),
typeof(bool),
true,
null,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_DrawMatch_Desc")));
Parameters.Add("RectangleThickness", new ProcessorParameter(
"RectangleThickness",
LocalizationHelper.GetString("TemplateMatchingProcessor_RectThickness"),
typeof(int),
2,
1,
8,
LocalizationHelper.GetString("TemplateMatchingProcessor_RectThickness_Desc")));
Parameters.Add("SearchRegionX", new ProcessorParameter(
"SearchRegionX",
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionX"),
typeof(int),
0,
0,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc")));
Parameters.Add("SearchRegionY", new ProcessorParameter(
"SearchRegionY",
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionY"),
typeof(int),
0,
0,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc")));
Parameters.Add("SearchRegionWidth", new ProcessorParameter(
"SearchRegionWidth",
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionWidth"),
typeof(int),
0,
0,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc")));
Parameters.Add("SearchRegionHeight", new ProcessorParameter(
"SearchRegionHeight",
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegionHeight"),
typeof(int),
0,
0,
null,
LocalizationHelper.GetString("TemplateMatchingProcessor_SearchRegion_Desc")));
_logger.Debug("InitializeParameters");
}
public override Image<Gray, byte> Process(Image<Gray, byte> inputImage)
{
var path = (GetParameter<string>("TemplatePath") ?? string.Empty).Trim();
var methodName = GetParameter<string>("MatchMethod") ?? "CcoeffNormed";
var threshold = GetParameter<double>("MatchThreshold");
var draw = GetParameter<bool>("DrawRectangle");
var thickness = GetParameter<int>("RectangleThickness");
var searchRx = GetParameter<int>("SearchRegionX");
var searchRy = GetParameter<int>("SearchRegionY");
var searchRw = GetParameter<int>("SearchRegionWidth");
var searchRh = GetParameter<int>("SearchRegionHeight");
OutputData.Clear();
var output = inputImage.Clone();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
_logger.Warning("TemplateMatching: invalid or missing template file: {Path}", path);
OutputData["Matched"] = false;
OutputData["Message"] = "Template file not found";
return output;
}
using var template = LoadTemplate(path);
if (template == null)
{
OutputData["Matched"] = false;
OutputData["Message"] = "Template load failed";
return output;
}
var searchRoi = ResolveSearchRoi(inputImage.Width, inputImage.Height, searchRx, searchRy, searchRw, searchRh);
var offsetX = searchRoi.X;
var offsetY = searchRoi.Y;
Image<Gray, byte>? roiImage = null;
try
{
inputImage.ROI = searchRoi;
roiImage = inputImage.Copy();
inputImage.ROI = Rectangle.Empty;
if (template.Width > roiImage.Width || template.Height > roiImage.Height)
{
_logger.Warning("TemplateMatching: template larger than search region ({Tw}x{Th} vs {Iw}x{Ih})",
template.Width, template.Height, roiImage.Width, roiImage.Height);
OutputData["Matched"] = false;
OutputData["Message"] = "Template larger than search region";
return output;
}
var method = ParseMethod(methodName);
using var resultMat = new Mat();
CvInvoke.MatchTemplate(roiImage, template, resultMat, method);
double minVal = 0, maxVal = 0;
Point minLoc = default, maxLoc = default;
CvInvoke.MinMaxLoc(resultMat, ref minVal, ref maxVal, ref minLoc, ref maxLoc);
var useMin = method == TemplateMatchingType.Sqdiff || method == TemplateMatchingType.SqdiffNormed;
var loc = useMin ? minLoc : maxLoc;
var score = useMin ? minVal : maxVal;
var matched = IsMatchAcceptable(method, minVal, maxVal, threshold);
var globalLoc = new Point(loc.X + offsetX, loc.Y + offsetY);
OutputData["Matched"] = matched;
OutputData["MatchScore"] = score;
OutputData["MatchX"] = globalLoc.X;
OutputData["MatchY"] = globalLoc.Y;
OutputData["TemplateWidth"] = template.Width;
OutputData["TemplateHeight"] = template.Height;
OutputData["MatchMethod"] = methodName;
if (matched && draw)
{
var rect = new Rectangle(globalLoc.X, globalLoc.Y, template.Width, template.Height);
CvInvoke.Rectangle(output, rect, new MCvScalar(255), thickness);
}
_logger.Debug("TemplateMatching: Matched={Matched}, Score={Score}, Origin=({X},{Y}), SearchRoi=({Rx},{Ry},{Rw},{Rh})",
matched, score, globalLoc.X, globalLoc.Y, searchRoi.X, searchRoi.Y, searchRoi.Width, searchRoi.Height);
return output;
}
finally
{
roiImage?.Dispose();
}
}
private static Rectangle ResolveSearchRoi(int imgW, int imgH, int rx, int ry, int rw, int rh)
{
if (rw <= 0 || rh <= 0)
return new Rectangle(0, 0, imgW, imgH);
rx = Math.Clamp(rx, 0, Math.Max(0, imgW - 1));
ry = Math.Clamp(ry, 0, Math.Max(0, imgH - 1));
rw = Math.Clamp(rw, 1, Math.Max(1, imgW - rx));
rh = Math.Clamp(rh, 1, Math.Max(1, imgH - ry));
return new Rectangle(rx, ry, rw, rh);
}
private static bool IsMatchAcceptable(TemplateMatchingType method, double minVal, double maxVal, double threshold)
{
return method switch
{
TemplateMatchingType.SqdiffNormed => minVal <= threshold,
TemplateMatchingType.Sqdiff => minVal <= threshold,
TemplateMatchingType.CcorrNormed or TemplateMatchingType.CcoeffNormed => maxVal >= threshold,
TemplateMatchingType.Ccorr or TemplateMatchingType.Ccoeff => maxVal >= threshold,
_ => maxVal >= threshold
};
}
private static TemplateMatchingType ParseMethod(string? name)
{
return name?.Trim() switch
{
"Sqdiff" => TemplateMatchingType.Sqdiff,
"SqdiffNormed" => TemplateMatchingType.SqdiffNormed,
"Ccorr" => TemplateMatchingType.Ccorr,
"CcorrNormed" => TemplateMatchingType.CcorrNormed,
"Ccoeff" => TemplateMatchingType.Ccoeff,
_ => TemplateMatchingType.CcoeffNormed
};
}
private static Image<Gray, byte>? LoadTemplate(string path)
{
try
{
using var raw = CvInvoke.Imread(path, ImreadModes.AnyDepth | ImreadModes.AnyColor);
if (raw.IsEmpty)
return null;
var templ = new Image<Gray, byte>(raw.Width, raw.Height);
if (raw.NumberOfChannels == 1)
raw.CopyTo(templ.Mat);
else
CvInvoke.CvtColor(raw, templ, ColorConversion.Bgr2Gray);
return templ;
}
catch (Exception ex)
{
_logger.Error(ex, "TemplateMatching: failed to load template {Path}", path);
return null;
}
}
}
@@ -25,5 +25,15 @@ namespace XplorePlane.Services
IDictionary<string, object> parameters,
IProgress<double> progress = null,
CancellationToken cancellationToken = default);
/// <summary>
/// 执行单算子并返回输出图像与算子 <see cref="XP.ImageProcessing.Core.ImageProcessorBase.OutputData"/> 快照(浅拷贝)。
/// </summary>
Task<(BitmapSource image, IReadOnlyDictionary<string, object> outputData)> ProcessImageWithOutputAsync(
BitmapSource source,
string processorName,
IDictionary<string, object> parameters,
IProgress<double> progress = null,
CancellationToken cancellationToken = default);
}
}
@@ -220,6 +220,18 @@ namespace XplorePlane.Services
IDictionary<string, object> parameters,
IProgress<double> progress = null,
CancellationToken cancellationToken = default)
{
var (image, _) = await ProcessImageWithOutputAsync(source, processorName, parameters, progress, cancellationToken)
.ConfigureAwait(false);
return image;
}
public async Task<(BitmapSource image, IReadOnlyDictionary<string, object> outputData)> ProcessImageWithOutputAsync(
BitmapSource source,
string processorName,
IDictionary<string, object> parameters,
IProgress<double> progress = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -250,7 +262,11 @@ namespace XplorePlane.Services
result.Freeze();
progress?.Report(1.0);
return result;
var snapshot = new Dictionary<string, object>(processor.OutputData.Count);
foreach (var kv in processor.OutputData)
snapshot[kv.Key] = kv.Value;
return (result, snapshot);
}
catch (OperationCanceledException)
{
@@ -265,7 +281,7 @@ namespace XplorePlane.Services
_logger.Error(ex, "Image processing failed for processor: {ProcessorName}", processorName);
throw new ImageProcessingException($"Image processing failed: {ex.Message}", ex);
}
}, cancellationToken);
}, cancellationToken).ConfigureAwait(false);
}
public void Dispose()
@@ -12,7 +12,8 @@ namespace XplorePlane.Services
("数学运算", "➗", 3),
("形态学处理", "⬚", 4),
("边缘检测", "📐", 5),
("检测分析", "🔎", 6),
("定位识别", "📌", 6),
("检测分析", "🔎", 7),
("其他", "⚙", 99),
};
@@ -46,6 +47,9 @@ namespace XplorePlane.Services
if (ContainsAny(operatorKey, "Edge"))
return "边缘检测";
if (ContainsAny(operatorKey, "TemplateMatching"))
return "定位识别";
if (ContainsAny(operatorKey, "Measurement", "Detection", "Contour", "FillRate", "Void", "Line", "PointToLine", "Ellipse", "Bga"))
return "检测分析";
@@ -129,6 +133,8 @@ namespace XplorePlane.Services
return "⬚";
if (ContainsAny(operatorKey, "Sobel", "Kirsch", "HorizontalEdge"))
return "📐";
if (ContainsAny(operatorKey, "TemplateMatching"))
return "🎯";
if (ContainsAny(operatorKey, "Contour"))
return "✏";
if (ContainsAny(operatorKey, "Measurement"))
@@ -17,6 +17,7 @@ using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.MainViewport;
using XplorePlane.Views;
using XP.Common.Logging.Interfaces;
using Prism.Events;
@@ -74,6 +75,7 @@ namespace XplorePlane.ViewModels.Cnc
SavePipelineCommand = new DelegateCommand(SavePipelineToModule);
SaveAsPipelineCommand = new DelegateCommand(async () => await SaveAsPipelineAsync());
LoadPipelineCommand = new DelegateCommand(async () => await LoadPipelineAsync());
OpenTemplateMatchingToolCommand = new DelegateCommand(OpenTemplateMatchingTool, CanOpenTemplateMatchingTool);
_editorViewModel.PropertyChanged += OnEditorPropertyChanged;
RefreshFromSelection();
@@ -84,9 +86,22 @@ namespace XplorePlane.ViewModels.Cnc
public PipelineNodeViewModel SelectedNode
{
get => _selectedNode;
set => SetProperty(ref _selectedNode, value);
set
{
if (!SetProperty(ref _selectedNode, value))
return;
RaisePropertyChanged(nameof(IsTemplateMatchingNodeSelected));
(OpenTemplateMatchingToolCommand as DelegateCommand)?.RaiseCanExecuteChanged();
}
}
public bool IsTemplateMatchingNodeSelected =>
SelectedNode != null &&
string.Equals(SelectedNode.OperatorKey, "TemplateMatching", StringComparison.Ordinal);
public ICommand OpenTemplateMatchingToolCommand { get; }
public string StatusMessage
{
get => _statusMessage;
@@ -141,6 +156,45 @@ namespace XplorePlane.ViewModels.Cnc
public ICommand LoadPipelineCommand { get; }
private static string? GetPipelineRecipeDirectory(string? currentFilePath)
{
if (string.IsNullOrWhiteSpace(currentFilePath))
return null;
try
{
var full = Path.GetFullPath(currentFilePath);
return Path.GetDirectoryName(full);
}
catch
{
return null;
}
}
private bool CanOpenTemplateMatchingTool() => IsTemplateMatchingNodeSelected;
private void OpenTemplateMatchingTool()
{
if (!CanOpenTemplateMatchingTool() || SelectedNode == null)
return;
try
{
var recipeDir = GetPipelineRecipeDirectory(_currentFilePath);
var source = _mainViewportService?.CurrentDisplayImage as BitmapSource
?? _mainViewportService?.LatestManualImage as BitmapSource;
var win = new TemplateMatchingToolWindow(SelectedNode, source, _imageProcessingService, _logger, recipeDir);
win.Owner = Application.Current?.MainWindow;
win.ShowDialog();
}
catch (Exception ex)
{
_logger.Warn("打开模板匹配工具窗失败: {Message}", ex.Message);
StatusMessage = "无法打开模板匹配工具:" + ex.Message;
}
}
private void OnEditorPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(CncEditorViewModel.SelectedNode))
@@ -585,6 +639,7 @@ namespace XplorePlane.ViewModels.Cnc
private void RaiseCommandCanExecuteChanged()
{
(AddOperatorCommand as DelegateCommand<string>)?.RaiseCanExecuteChanged();
(OpenTemplateMatchingToolCommand as DelegateCommand)?.RaiseCanExecuteChanged();
}
private static object ConvertSavedValue(object savedValue, Type targetType)
@@ -38,5 +38,9 @@ namespace XplorePlane.ViewModels
ICommand SaveAsPipelineCommand { get; }
ICommand LoadPipelineCommand { get; }
bool IsTemplateMatchingNodeSelected { get; }
ICommand OpenTemplateMatchingToolCommand { get; }
}
}
@@ -121,7 +121,8 @@ namespace XplorePlane.ViewModels
"数学运算" => 3,
"形态学处理" => 4,
"边缘检测" => 5,
"检测分析" => 6,
"定位识别" => 6,
"检测分析" => 7,
_ => 99
};
}
@@ -8,6 +8,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using XP.Common.Logging.Interfaces;
@@ -15,6 +16,7 @@ using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Storage;
using XplorePlane.Views;
namespace XplorePlane.ViewModels
{
@@ -46,6 +48,8 @@ namespace XplorePlane.ViewModels
private CancellationTokenSource _executionCts;
private CancellationTokenSource _debounceCts;
private readonly DelegateCommand _openTemplateMatchingToolCommand;
public PipelineEditorViewModel(
IImageProcessingService imageProcessingService,
IPipelineExecutionService executionService,
@@ -80,6 +84,7 @@ namespace XplorePlane.ViewModels
LoadImageCommand = new DelegateCommand(LoadImage);
MoveNodeUpCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<PipelineNodeViewModel>(MoveNodeDown);
_openTemplateMatchingToolCommand = new DelegateCommand(OpenTemplateMatchingTool, CanOpenTemplateMatchingTool);
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
.Subscribe(OnManualImageLoaded);
@@ -95,11 +100,20 @@ namespace XplorePlane.ViewModels
get => _selectedNode;
set
{
if (SetProperty(ref _selectedNode, value) && value != null)
LoadNodeParameters(value);
if (SetProperty(ref _selectedNode, value))
{
if (value != null)
LoadNodeParameters(value);
RaisePropertyChanged(nameof(IsTemplateMatchingNodeSelected));
_openTemplateMatchingToolCommand.RaiseCanExecuteChanged();
}
}
}
public bool IsTemplateMatchingNodeSelected =>
SelectedNode != null &&
string.Equals(SelectedNode.OperatorKey, "TemplateMatching", StringComparison.Ordinal);
public BitmapSource SourceImage
{
get => _sourceImage;
@@ -202,6 +216,7 @@ namespace XplorePlane.ViewModels
public DelegateCommand<PipelineNodeViewModel> MoveNodeUpCommand { get; }
public DelegateCommand<PipelineNodeViewModel> MoveNodeDownCommand { get; }
public ICommand OpenTemplateMatchingToolCommand => _openTemplateMatchingToolCommand;
ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand;
ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand;
@@ -413,6 +428,43 @@ namespace XplorePlane.ViewModels
}
}
private static string? GetPipelineRecipeDirectory(string? currentFilePath)
{
if (string.IsNullOrWhiteSpace(currentFilePath))
return null;
try
{
var full = Path.GetFullPath(currentFilePath);
return Path.GetDirectoryName(full);
}
catch
{
return null;
}
}
private bool CanOpenTemplateMatchingTool() => IsTemplateMatchingNodeSelected;
private void OpenTemplateMatchingTool()
{
if (!CanOpenTemplateMatchingTool() || SelectedNode == null)
return;
try
{
var recipeDir = GetPipelineRecipeDirectory(_currentFilePath);
var win = new TemplateMatchingToolWindow(SelectedNode, SourceImage, _imageProcessingService, _logger, recipeDir);
win.Owner = Application.Current?.MainWindow;
win.ShowDialog();
}
catch (Exception ex)
{
_logger.Warn("打开模板匹配工具窗失败: {Message}", ex.Message);
SetInfoStatus("无法打开模板匹配工具:" + ex.Message);
}
}
private async Task ExecutePipelineAsync()
{
if (SourceImage == null || IsExecuting)
@@ -0,0 +1,538 @@
using Microsoft.Win32;
using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services;
namespace XplorePlane.ViewModels
{
/// <summary>
/// 模板匹配算子独立工具窗:VisionMaster 风格双 ROI(模板 / 搜索)、学习模板、试跑。
/// </summary>
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<TemplateMatchingToolViewModel>() ?? 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<ProcessorParameterVM>(
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<ProcessorParameterVM> OtherParameters { get; }
/// <summary>供画布刷新:任意 ROI 或搜索区参数变更时递增。</summary>
public int RoiOverlayRevision => _roiOverlayRevision;
private void BumpRoiOverlayRevision()
{
_roiOverlayRevision++;
RaisePropertyChanged(nameof(RoiOverlayRevision));
}
/// <summary>true = 编辑搜索框;false = 编辑模板框(类 VM)。</summary>
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<string, object> 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
};
}
}
}
@@ -301,13 +301,41 @@
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,6">
<DockPanel Margin="0,0,0,4" LastChildFill="False">
<Button
MinWidth="120"
Height="28"
Margin="8,0,0,0"
Padding="8,2"
VerticalAlignment="Center"
Background="Transparent"
BorderBrush="#CDCBCB"
BorderThickness="1"
Command="{Binding OpenTemplateMatchingToolCommand}"
Content="模板匹配工具…"
Cursor="Hand"
DockPanel.Dock="Right"
FontFamily="{StaticResource UiFont}"
FontSize="10.5">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsTemplateMatchingNodeSelected}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<TextBlock
Margin="0,0,0,4"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="参数配置" />
</DockPanel>
<ItemsControl ItemsSource="{Binding SelectedNode.Parameters}">
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
@@ -0,0 +1,390 @@
<Window
x:Class="XplorePlane.Views.TemplateMatchingToolWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="模板匹配工具"
Width="780"
Height="700"
MinWidth="560"
MinHeight="520"
ResizeMode="CanResizeWithGrip"
Loaded="TemplateMatchingToolWindow_OnLoaded"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="#F5F5F5"
FontFamily="Microsoft YaHei UI">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<FontFamily x:Key="UiFont">Microsoft YaHei UI</FontFamily>
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="#E0E0E0" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="Margin" Value="0,0,0,10" />
</Style>
<Style x:Key="Lbl" TargetType="TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#555" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<DataTemplate x:Key="ProcessorParamTemplate">
<Grid Margin="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="108" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding DisplayName}"
TextTrimming="CharacterEllipsis" />
<Grid Grid.Column="1">
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsSliderInput}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="56" />
</Grid.ColumnDefinitions>
<Slider
Grid.Column="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsSnapToTickEnabled="{Binding IsIntegerSlider}"
LargeChange="{Binding SliderTickFrequency}"
Maximum="{Binding SliderMaximum}"
Minimum="{Binding SliderMinimum}"
SmallChange="{Binding SliderTickFrequency}"
TickFrequency="{Binding SliderTickFrequency}"
Value="{Binding SliderValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Border
Grid.Column="1"
Padding="2,2"
Background="#F8F8F8"
BorderBrush="#CDCBCB"
BorderThickness="1"
CornerRadius="2">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
Text="{Binding DisplayValueText}" />
</Border>
</Grid>
<TextBox
Grid.Column="1"
Padding="4,3"
BorderBrush="#CDCBCB"
BorderThickness="1"
FontSize="11"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Background" Value="White" />
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsTextInput}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
<DataTrigger Binding="{Binding IsValueValid}" Value="False">
<Setter Property="BorderBrush" Value="#D9534F" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<ComboBox
Grid.Column="1"
MinHeight="24"
Padding="4,2"
BorderBrush="#CDCBCB"
FontSize="11"
ItemsSource="{Binding Options}"
SelectedItem="{Binding SelectedOption, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<ComboBox.Style>
<Style TargetType="ComboBox">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasOptions}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
<CheckBox
Grid.Column="1"
VerticalAlignment="Center"
FontSize="11"
IsChecked="{Binding BoolValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<CheckBox.Style>
<Style TargetType="CheckBox">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBool}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</CheckBox.Style>
</CheckBox>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Margin="14,12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Margin="0,0,0,8"
FontSize="12"
FontWeight="SemiBold"
Foreground="#333"
Text="模板与匹配参数(修改会同步到当前链路节点)" />
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="360" MinWidth="260" />
<ColumnDefinition Width="*" MinWidth="320" />
</Grid.ColumnDefinitions>
<ScrollViewer
Grid.Column="0"
Margin="0,0,10,0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<Border Style="{StaticResource Card}">
<StackPanel>
<TextBlock
Margin="0,0,0,8"
FontSize="12"
FontWeight="SemiBold"
Foreground="#333"
Text="模板与搜索区域" />
<TextBlock Style="{StaticResource Lbl}" Text="模板文件" />
<DockPanel LastChildFill="True">
<Button
Width="72"
Margin="8,0,0,0"
Padding="6,4"
VerticalAlignment="Center"
Command="{Binding BrowseTemplateCommand}"
Content="浏览…"
DockPanel.Dock="Right" />
<TextBox
Padding="6,4"
VerticalContentAlignment="Center"
FontSize="11"
Text="{Binding TemplatePathParameter.Value, UpdateSourceTrigger=PropertyChanged}" />
</DockPanel>
<TextBlock
Margin="0,12,0,4"
Style="{StaticResource Lbl}"
Text="学习模板(保存到配方目录)" />
<TextBlock
Margin="0,0,0,6"
FontSize="10"
Foreground="#666"
Text="{Binding RecipeDirectoryHint}"
TextWrapping="Wrap" />
<StackPanel Margin="0,0,0,8" Orientation="Horizontal">
<RadioButton
Margin="0,0,20,0"
VerticalContentAlignment="Center"
Content="编辑模板框"
FontSize="11"
Foreground="#333"
GroupName="RoiEdit"
IsChecked="{Binding EditTemplateRoiActive, Mode=TwoWay}" />
<RadioButton
VerticalContentAlignment="Center"
Content="编辑搜索框"
FontSize="11"
Foreground="#333"
GroupName="RoiEdit"
IsChecked="{Binding EditSearchRoiActive, Mode=TwoWay}" />
</StackPanel>
<TextBlock
Margin="0,0,0,4"
FontSize="11"
Foreground="#555"
Text="在右侧画布上拖动边角缩放、框内平移;在空白处拖拽新建当前类型的矩形。"
TextWrapping="Wrap" />
<TextBlock
Margin="0,0,0,2"
FontSize="11"
Foreground="#2E7D32"
Text="{Binding TemplateRegionDisplay}"
TextWrapping="Wrap" />
<TextBlock
Margin="0,0,0,8"
FontSize="11"
Foreground="#0277BD"
Text="{Binding SearchRegionDisplay}"
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal">
<Button
MinWidth="120"
Padding="10,6"
Command="{Binding GenerateTemplateFromSourceCommand}"
Content="学习"
FontSize="11" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="10"
Foreground="#666"
Text="将「模板框」写入文件并更新路径"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</Border>
<Border Margin="0,10,0,0" Style="{StaticResource Card}">
<ItemsControl ItemTemplate="{StaticResource ProcessorParamTemplate}" ItemsSource="{Binding OtherParameters}" />
</Border>
<Border Margin="0,10,0,0" Style="{StaticResource Card}">
<StackPanel>
<TextBlock Style="{StaticResource Lbl}" Text="试跑" />
<TextBlock
Margin="0,0,0,8"
FontSize="11"
Foreground="#666"
TextWrapping="Wrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="已加载链路源图,可进行试跑。" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasSourceImage}" Value="False">
<Setter Property="Text" Value="当前未加载链路源图,无法试跑;可先设置模板路径与参数,回到链路加载图像后再试跑。" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<StackPanel Margin="0,0,0,10" Orientation="Horizontal">
<Button
MinWidth="120"
Padding="12,6"
Command="{Binding RunTestCommand}"
Content="试跑匹配"
IsDefault="True" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="11"
Foreground="#888"
Text="处理中…">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBusy}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<TextBlock
Margin="0,0,0,8"
FontSize="11"
Foreground="#666"
Text="{Binding RunResultText}"
TextWrapping="Wrap" />
<Border
Background="#F8F8F8"
BorderBrush="#CDCBCB"
BorderThickness="1"
CornerRadius="4">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasPreview}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<Image
MaxHeight="240"
Margin="4"
HorizontalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding PreviewImage}"
Stretch="Uniform" />
</Border>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Border
Grid.Column="1"
Margin="0"
Style="{StaticResource Card}">
<Grid
x:Name="RoiViewportHost"
MinHeight="280"
Background="#F8F8F8"
ClipToBounds="True">
<Image
x:Name="CropSourceImage"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding SourceImage}"
Stretch="Uniform" />
<Canvas
x:Name="CropOverlayCanvas"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Background="Transparent"
ClipToBounds="True"
MouseLeftButtonDown="CropOverlay_OnMouseLeftButtonDown"
MouseLeftButtonUp="CropOverlay_OnMouseLeftButtonUp"
MouseMove="CropOverlay_OnMouseMove"
LostMouseCapture="CropOverlay_OnLostMouseCapture" />
</Grid>
</Border>
</Grid>
<Button
Grid.Row="2"
HorizontalAlignment="Right"
MinWidth="88"
Margin="0,10,0,0"
Padding="10,5"
Click="OnCloseClick"
Content="关闭"
IsCancel="True" />
</Grid>
</Window>
@@ -0,0 +1,558 @@
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
{
public partial class TemplateMatchingToolWindow : Window
{
private const double HandleHitTolerance = 10;
private enum DragKind
{
None,
CreateRect,
MoveRect,
ResizeRect
}
private enum CornerKind
{
None,
Nw,
Ne,
Se,
Sw
}
private DragKind _dragKind;
private CornerKind _resizeCorner;
/// <summary>拖拽全程在 Canvas DIP 空间计算草稿(与气泡测量 ROI 一致),仅在 MouseUp 时换算为像素。</summary>
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;
/// <summary>
/// 与 WPF <see cref="Image"/> + <see cref="Stretch.Uniform"/> 一致:用位图源 DPI 得到逻辑尺寸(DIP),再算 letterbox。
/// </summary>
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;
}
}
}