基于灰度的模板匹配算子开发及集成
This commit is contained in:
+153
@@ -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>
|
||||
|
||||
@@ -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.1–0.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user