feat: 模板匹配助手窗口与主视口 ROI 清除逻辑

- 新增模板助手/批量测试窗口、事件与 ViewModel,主窗口入口与 App 注册

- 运行匹配或批量测试前发布清除事件;视口通过 VisualTreeHelper 从父 Panel 移除持久虚线 ROI,避免 FindChild 失败时框残留

- TemplateMatchNative 等相关调整

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
李伟
2026-05-15 10:42:20 +08:00
parent 82465e6510
commit e0eec42a2f
10 changed files with 1424 additions and 4 deletions
@@ -30,6 +30,12 @@ public struct TM_Params
/// <summary>是否亚像素估计 (1=是, 0=否)</summary> /// <summary>是否亚像素估计 (1=是, 0=否)</summary>
public int UseSubPixel; public int UseSubPixel;
/// <summary>
/// 开启亚像素且角度容差绝对值超过该值时,托管封装会在调用原生库前关闭亚像素,
/// 以避免部分版本 TemplateMatchLib 在 Debug 下出现 vector 越界断言。
/// </summary>
public const double SubPixelAngleSafetyLimitDegrees = 90.0;
/// <summary> /// <summary>
/// 创建默认参数 /// 创建默认参数
/// </summary> /// </summary>
@@ -168,9 +174,33 @@ public sealed class TemplateMatcherHandle : IDisposable
public TM_Result[] Match(IntPtr srcData, int srcWidth, int srcHeight, int srcStep, TM_Params param) public TM_Result[] Match(IntPtr srcData, int srcWidth, int srcHeight, int srcStep, TM_Params param)
{ {
ThrowIfDisposed(); ThrowIfDisposed();
var results = new TM_Result[param.MaxCount];
// 与库默认一致并对齐已知崩溃组合:Debug 下亚像素 + 大角度容差易触发 vector 越界断言;
// 金字塔最小面积过小也可能与内部层级假设不一致。
int tw = 0, th = 0, _pyramidLayers = 0;
_ = GetTemplateInfo(out tw, out th, out _pyramidLayers);
int templatePixels = Math.Max(0, tw) * Math.Max(0, th);
int maxCount = Math.Clamp(param.MaxCount, 1, 100);
int minReduce = (int)Math.Clamp(param.MinReduceArea, 64, 4096);
if (templatePixels >= 512)
minReduce = Math.Max(256, minReduce);
if (templatePixels > 0)
minReduce = Math.Min(minReduce, templatePixels);
minReduce = Math.Max(64, minReduce);
int useSubPixel = param.UseSubPixel;
if (useSubPixel != 0 && Math.Abs(param.ToleranceAngle) > TM_Params.SubPixelAngleSafetyLimitDegrees)
useSubPixel = 0;
var p = param;
p.MaxCount = maxCount;
p.MinReduceArea = minReduce;
p.UseSubPixel = useSubPixel;
var results = new TM_Result[p.MaxCount];
int count = TemplateMatchNative.TM_Match(_handle, srcData, srcWidth, srcHeight, srcStep, int count = TemplateMatchNative.TM_Match(_handle, srcData, srcWidth, srcHeight, srcStep,
ref param, results, param.MaxCount); ref p, results, p.MaxCount);
if (count <= 0) if (count <= 0)
return Array.Empty<TM_Result>(); return Array.Empty<TM_Result>();
+2
View File
@@ -47,6 +47,7 @@ using XplorePlane.Services.Recipe;
using XplorePlane.Services.Storage; using XplorePlane.Services.Storage;
using XplorePlane.ViewModels; using XplorePlane.ViewModels;
using XplorePlane.ViewModels.Cnc; using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.ImageProcessing;
using XplorePlane.Views; using XplorePlane.Views;
using XplorePlane.Views.Cnc; using XplorePlane.Views.Cnc;
@@ -422,6 +423,7 @@ namespace XplorePlane
// 注册流水线 ViewModel(每次解析创建新实例) // 注册流水线 ViewModel(每次解析创建新实例)
containerRegistry.Register<PipelineEditorViewModel>(); containerRegistry.Register<PipelineEditorViewModel>();
containerRegistry.Register<OperatorToolboxViewModel>(); containerRegistry.Register<OperatorToolboxViewModel>();
containerRegistry.Register<TemplateMatchAssistantViewModel>();
// 注册硬件库的 ViewModel(供 ViewModelLocator 自动装配) // 注册硬件库的 ViewModel(供 ViewModelLocator 自动装配)
containerRegistry.Register<XP.Hardware.RaySource.ViewModels.RaySourceOperateViewModel>(); containerRegistry.Register<XP.Hardware.RaySource.ViewModels.RaySourceOperateViewModel>();
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Windows;
using Prism.Events;
namespace XplorePlane.Events;
/// <summary>
/// 进入「在视口上框选模板 ROI」模式(与主画布 Preview 鼠标逻辑配合)。
/// </summary>
public class TemplateMatchEnterRoiModeEvent : PubSubEvent { }
/// <summary>
/// 模板 ROI 框选完成(图像/画布像素坐标,与白底检测 ROI 约定一致)。仅表示区域已确定,不表示已训练。
/// </summary>
public class TemplateMatchRoiDrawnEvent : PubSubEvent<Int32Rect> { }
/// <summary>
/// 清除视口上的模板助手持久 ROI 框(例如加载模型后或重置时)。
/// </summary>
public class TemplateMatchClearRoiOverlayEvent : PubSubEvent { }
/// <summary>
/// 单次模板匹配试跑结果,供主视图叠加层绘制。
/// </summary>
public class TemplateMatchHitDto
{
public double CenterX { get; set; }
public double CenterY { get; set; }
public double Angle { get; set; }
public double Score { get; set; }
public double LtX { get; set; }
public double LtY { get; set; }
public double RtX { get; set; }
public double RtY { get; set; }
public double RbX { get; set; }
public double RbY { get; set; }
public double LbX { get; set; }
public double LbY { get; set; }
}
public class TemplateMatchPreviewPayload
{
public List<TemplateMatchHitDto> Hits { get; set; } = new();
public double MatchTimeMs { get; set; }
}
public class TemplateMatchPreviewResultEvent : PubSubEvent<TemplateMatchPreviewPayload> { }
@@ -0,0 +1,507 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Emgu.CV;
using Emgu.CV.Structure;
using Microsoft.Win32;
using Prism.Commands;
using Prism.Events;
using Prism.Ioc;
using Prism.Mvvm;
using XP.Common.Logging.Interfaces;
using XP.ImageProcessing.Processors;
using XplorePlane.Events;
using XplorePlane.Services.MainViewport;
using XplorePlane.ViewModels;
using Serilog;
namespace XplorePlane.ViewModels.ImageProcessing;
/// <summary>
/// 旋转模板匹配助手:框选 ROI、从 ROI 训练、参数、加载/保存模型、在当页试匹配;批量测试见 <see cref="Batch"/>。
/// </summary>
public class TemplateMatchAssistantViewModel : BindableBase, IDisposable
{
private static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TemplateMatchAssistantViewModel>();
private readonly IEventAggregator _eventAggregator;
private readonly IContainerProvider _containerProvider;
private readonly ILoggerService _logger;
private readonly object _matcherLock = new();
private readonly TemplateMatchBatchViewModel _batch;
private SubscriptionToken? _roiDrawnToken;
private TemplateMatcherHandle? _matcher;
private bool _disposed;
private Int32Rect _pendingTemplateRoi;
private bool _hasPendingTemplateRoi;
private string _statusMessage = "可先加载模型,或点击「框选模板 ROI」后在主视图框选,再点「从 ROI 训练模板」。批量测试请切换到「批量测试」选项卡。";
private double _matchThreshold = 0.75;
private double _toleranceAngle;
private double _maxMatchCount = 5;
private double _maxOverlap = 0.3;
private double _minReduceArea = 256;
private bool _useSimd = true;
private bool _useSubPixel;
private bool _isModelReady;
public TemplateMatchAssistantViewModel(
IEventAggregator eventAggregator,
IContainerProvider containerProvider,
ILoggerService logger)
{
_eventAggregator = eventAggregator;
_containerProvider = containerProvider;
_logger = logger;
SelectTemplateRoiCommand = new DelegateCommand(ExecuteSelectTemplateRoi);
LearnFromRoiCommand = new DelegateCommand(ExecuteLearnFromRoi, () => _hasPendingTemplateRoi);
LoadModelCommand = new DelegateCommand(ExecuteLoadModel);
SaveModelCommand = new DelegateCommand(ExecuteSaveModel, () => _isModelReady && _matcher != null);
RunMatchCommand = new DelegateCommand(ExecuteRunMatch, () => _isModelReady && _matcher != null);
_batch = new TemplateMatchBatchViewModel(
this,
_eventAggregator,
_containerProvider.Resolve<IMainViewportService>());
_roiDrawnToken = _eventAggregator.GetEvent<TemplateMatchRoiDrawnEvent>()
.Subscribe(OnTemplateRoiDrawn, ThreadOption.UIThread);
}
/// <summary>批量测试子页(与助手共用同一模型与参数)。</summary>
public TemplateMatchBatchViewModel Batch => _batch;
public DelegateCommand SelectTemplateRoiCommand { get; }
public DelegateCommand LearnFromRoiCommand { get; }
public DelegateCommand LoadModelCommand { get; }
public DelegateCommand SaveModelCommand { get; }
public DelegateCommand RunMatchCommand { get; }
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
public double MatchThreshold
{
get => _matchThreshold;
set => SetProperty(ref _matchThreshold, Math.Clamp(value, 0, 1));
}
public double ToleranceAngle
{
get => _toleranceAngle;
set => SetProperty(ref _toleranceAngle, Math.Clamp(value, 0, 180));
}
/// <summary>最大匹配数(滑块 1~100,运行匹配时取整)。</summary>
public double MaxMatchCount
{
get => _maxMatchCount;
set => SetProperty(ref _maxMatchCount, Math.Clamp(value, 1, 100));
}
public double MaxOverlap
{
get => _maxOverlap;
set => SetProperty(ref _maxOverlap, Math.Clamp(value, 0, 1));
}
/// <summary>金字塔最小面积(滑块 64~4096,运行匹配时取整)。</summary>
public double MinReduceArea
{
get => _minReduceArea;
set => SetProperty(ref _minReduceArea, Math.Clamp(value, 64, 4096));
}
public bool UseSimd
{
get => _useSimd;
set => SetProperty(ref _useSimd, value);
}
public bool UseSubPixel
{
get => _useSubPixel;
set => SetProperty(ref _useSubPixel, value);
}
public bool IsModelReady
{
get => _isModelReady;
private set
{
if (SetProperty(ref _isModelReady, value))
{
SaveModelCommand.RaiseCanExecuteChanged();
RunMatchCommand.RaiseCanExecuteChanged();
}
}
}
/// <summary>是否已有框选完成的模板 ROI(与是否已训练无关)。</summary>
public bool HasPendingTemplateRoi
{
get => _hasPendingTemplateRoi;
private set
{
if (SetProperty(ref _hasPendingTemplateRoi, value))
LearnFromRoiCommand.RaiseCanExecuteChanged();
}
}
/// <summary>
/// 清除主视图上框选的模板学习 ROI 叠加,并重置待训练 ROI 状态(运行匹配或开始批量测试前调用)。
/// </summary>
public void ClearTemplateLearningRoiOnViewport()
{
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
HasPendingTemplateRoi = false;
}
private void ExecuteSelectTemplateRoi()
{
_eventAggregator.GetEvent<TemplateMatchEnterRoiModeEvent>().Publish();
StatusMessage = "请在主视图图像上拖拽框选模板区域;框选完成后 ROI 会保留在图上,再点击「从 ROI 训练模板」。";
}
private void OnTemplateRoiDrawn(Int32Rect roi)
{
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
var imageSource = viewportVm?.ImageSource as BitmapSource;
if (imageSource == null)
{
StatusMessage = "当前无主视图图像,无法记录模板 ROI。";
HasPendingTemplateRoi = false;
return;
}
int imgW = imageSource.PixelWidth;
int imgH = imageSource.PixelHeight;
int rx = Math.Clamp(roi.X, 0, Math.Max(0, imgW - 1));
int ry = Math.Clamp(roi.Y, 0, Math.Max(0, imgH - 1));
int rw = Math.Clamp(roi.Width, 1, Math.Max(1, imgW - rx));
int rh = Math.Clamp(roi.Height, 1, Math.Max(1, imgH - ry));
_pendingTemplateRoi = new Int32Rect(rx, ry, rw, rh);
HasPendingTemplateRoi = true;
StatusMessage = $"已框选模板区域 {rw}×{rh} 像素。请点击「从 ROI 训练模板」。";
}
private void ExecuteLearnFromRoi()
{
if (!HasPendingTemplateRoi) return;
try
{
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
var imageSource = viewportVm?.ImageSource as BitmapSource;
if (imageSource == null)
{
StatusMessage = "当前无主视图图像,无法学习模板。";
return;
}
BitmapSource gray = imageSource.Format == PixelFormats.Gray8
? imageSource
: new FormatConvertedBitmap(imageSource, PixelFormats.Gray8, null, 0);
int imgW = gray.PixelWidth;
int imgH = gray.PixelHeight;
int rx = Math.Clamp(_pendingTemplateRoi.X, 0, imgW - 1);
int ry = Math.Clamp(_pendingTemplateRoi.Y, 0, imgH - 1);
int rw = Math.Clamp(_pendingTemplateRoi.Width, 1, imgW - rx);
int rh = Math.Clamp(_pendingTemplateRoi.Height, 1, imgH - ry);
var pixels = new byte[rw * rh];
gray.CopyPixels(new Int32Rect(rx, ry, rw, rh), pixels, rw, 0);
using var roiImage = new Image<Gray, byte>(rw, rh);
for (int y = 0; y < rh; y++)
for (int x = 0; x < rw; x++)
roiImage.Data[y, x, 0] = pixels[y * rw + x];
lock (_matcherLock)
{
_matcher?.Dispose();
_matcher = new TemplateMatcherHandle();
IntPtr p = roiImage.Mat.DataPointer;
int step = (int)roiImage.Mat.Step;
if (!_matcher.LearnPattern(p, rw, rh, step))
{
_matcher.Dispose();
_matcher = null;
IsModelReady = false;
StatusMessage = "模板学习失败。";
_logger.Warn("Template assistant: LearnPattern failed for ROI {0},{1},{2},{3}", rx, ry, rw, rh);
return;
}
}
IsModelReady = true;
StatusMessage = $"模板已从 ROI 学习成功({rw}×{rh} 像素)。可保存模型或运行匹配。";
_logger.Info("Template assistant: learned from ROI {0},{1},{2},{3}", rx, ry, rw, rh);
}
catch (Exception ex)
{
Log.Error(ex, "Template assistant ROI learn failed");
StatusMessage = $"学习失败: {ex.Message}";
IsModelReady = false;
}
}
private void ExecuteLoadModel()
{
var dlg = new OpenFileDialog
{
Title = "加载模板模型",
Filter = "模板模型|*.tmmodel;*.tm|所有文件|*.*"
};
if (dlg.ShowDialog() != true) return;
try
{
lock (_matcherLock)
{
_matcher?.Dispose();
_matcher = new TemplateMatcherHandle();
if (!_matcher.LoadModel(dlg.FileName))
{
_matcher.Dispose();
_matcher = null;
IsModelReady = false;
StatusMessage = "模型加载失败。";
return;
}
}
HasPendingTemplateRoi = false;
_eventAggregator.GetEvent<TemplateMatchClearRoiOverlayEvent>().Publish();
IsModelReady = true;
StatusMessage = $"已加载模型: {Path.GetFileName(dlg.FileName)}";
}
catch (Exception ex)
{
Log.Error(ex, "LoadModel failed");
StatusMessage = $"加载失败: {ex.Message}";
IsModelReady = false;
}
}
private void ExecuteSaveModel()
{
if (_matcher == null || !IsModelReady) return;
var dlg = new SaveFileDialog
{
Title = "保存模板模型",
Filter = "模板模型|*.tmmodel|所有文件|*.*",
DefaultExt = ".tmmodel"
};
if (dlg.ShowDialog() != true) return;
try
{
bool ok;
lock (_matcherLock)
ok = _matcher != null && _matcher.SaveModel(dlg.FileName);
if (ok)
StatusMessage = $"模型已保存: {dlg.FileName}";
else
StatusMessage = "模型保存失败。";
}
catch (Exception ex)
{
Log.Error(ex, "SaveModel failed");
StatusMessage = $"保存失败: {ex.Message}";
}
}
private void ExecuteRunMatch()
{
if (_matcher == null || !IsModelReady) return;
ClearTemplateLearningRoiOnViewport();
try
{
var viewportVm = _containerProvider.Resolve<ViewportPanelViewModel>();
var imageSource = viewportVm?.ImageSource as BitmapSource;
if (imageSource == null)
{
StatusMessage = "当前无主视图图像。";
return;
}
using Image<Gray, byte>? full = BitmapSourceToGrayImage(imageSource);
if (full == null) return;
bool forcedSubPixelOff = UseSubPixel &&
Math.Abs(ToleranceAngle) > TM_Params.SubPixelAngleSafetyLimitDegrees;
int templatePixels = 0;
lock (_matcherLock)
{
if (_matcher != null && _matcher.GetTemplateInfo(out int tw, out int th, out _))
templatePixels = Math.Max(0, tw) * Math.Max(0, th);
}
bool bumpedMinReduce = templatePixels >= 512 &&
(int)Math.Clamp(Math.Round(MinReduceArea), 64, 4096) < 256;
if (!TryMatchGrayImage(full, out IReadOnlyList<TemplateMatchHitDto> hitsReadonly, out double t, out string? matchErr))
{
if (matchErr != null && matchErr.Contains("TemplateMatchLib", StringComparison.OrdinalIgnoreCase))
StatusMessage = "未找到 TemplateMatchLib.dll,请确认已复制到输出目录。";
else
StatusMessage = string.IsNullOrEmpty(matchErr) ? "匹配失败。" : matchErr;
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(new TemplateMatchPreviewPayload());
return;
}
var hits = new List<TemplateMatchHitDto>(hitsReadonly);
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(
new TemplateMatchPreviewPayload { Hits = hits, MatchTimeMs = t });
StatusMessage = hits.Count == 0
? $"未找到匹配(耗时 {t:F1} ms)。可调低阈值或角度范围。"
: $"匹配到 {hits.Count} 个目标,耗时 {t:F1} ms。";
if (forcedSubPixelOff)
StatusMessage += $" 已自动关闭亚像素(角度容差>{TM_Params.SubPixelAngleSafetyLimitDegrees:F0}° 时匹配库易崩溃)。";
if (bumpedMinReduce)
StatusMessage += " 已将金字塔最小面积提升至不低于 256(与库默认一致)。";
}
catch (Exception ex)
{
Log.Error(ex, "RunMatch failed");
StatusMessage = $"匹配失败: {ex.Message}";
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(new TemplateMatchPreviewPayload());
}
}
private TM_Params BuildCurrentMatchParams() => new TM_Params
{
Score = MatchThreshold,
ToleranceAngle = ToleranceAngle,
MaxOverlap = MaxOverlap,
MaxCount = (int)Math.Clamp(Math.Round(MaxMatchCount), 1, 100),
MinReduceArea = (int)Math.Clamp(Math.Round(MinReduceArea), 64, 4096),
UseSIMD = UseSimd ? 1 : 0,
UseSubPixel = UseSubPixel ? 1 : 0
};
/// <summary>
/// 使用当前助手参数对灰度图做模板匹配(与单张「运行匹配」一致)。供批量测试在后台线程调用,内部已加锁。
/// </summary>
public bool TryMatchGrayImage(Image<Gray, byte> fullImage, out IReadOnlyList<TemplateMatchHitDto> hits,
out double matchTimeMs, out string? errorMessage)
{
hits = Array.Empty<TemplateMatchHitDto>();
matchTimeMs = 0;
errorMessage = null;
if (fullImage == null)
{
errorMessage = "图像为空";
return false;
}
lock (_matcherLock)
{
if (_matcher == null || !IsModelReady)
{
errorMessage = "模型未就绪";
return false;
}
try
{
var param = BuildCurrentMatchParams();
var results = _matcher.Match(
fullImage.Mat.DataPointer,
fullImage.Width,
fullImage.Height,
(int)fullImage.Mat.Step,
param);
matchTimeMs = _matcher.LastMatchTime;
var list = new List<TemplateMatchHitDto>();
foreach (var r in results)
{
list.Add(new TemplateMatchHitDto
{
CenterX = r.CenterX,
CenterY = r.CenterY,
Angle = r.Angle,
Score = r.Score,
LtX = r.LtX, LtY = r.LtY,
RtX = r.RtX, RtY = r.RtY,
RbX = r.RbX, RbY = r.RbY,
LbX = r.LbX, LbY = r.LbY
});
}
hits = list;
return true;
}
catch (DllNotFoundException)
{
errorMessage = "TemplateMatchLib.dll 未找到";
return false;
}
catch (Exception ex)
{
Log.Error(ex, "TryMatchGrayImage failed");
errorMessage = ex.Message;
return false;
}
}
}
internal static Image<Gray, byte>? BitmapSourceToGrayImage(BitmapSource bitmapSource)
{
BitmapSource source = bitmapSource.Format == PixelFormats.Gray8
? bitmapSource
: new FormatConvertedBitmap(bitmapSource, PixelFormats.Gray8, null, 0);
int width = source.PixelWidth;
int height = source.PixelHeight;
if (width < 1 || height < 1) return null;
int stride = width;
var pixels = new byte[width * height];
source.CopyPixels(pixels, stride, 0);
var image = new Image<Gray, byte>(width, height);
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
image.Data[y, x, 0] = pixels[y * stride + x];
return image;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_roiDrawnToken != null)
{
_eventAggregator.GetEvent<TemplateMatchRoiDrawnEvent>().Unsubscribe(_roiDrawnToken);
_roiDrawnToken = null;
}
_batch.Dispose();
lock (_matcherLock)
{
_matcher?.Dispose();
_matcher = null;
}
}
}
@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Microsoft.Win32;
using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using XplorePlane.Events;
using XplorePlane.Services.MainViewport;
namespace XplorePlane.ViewModels.ImageProcessing;
/// <summary>单张批量匹配结果行(绑定到 DataGrid)。</summary>
public sealed class TemplateMatchBatchRow
{
public string FileName { get; init; } = "";
public string FullPath { get; init; } = "";
/// <summary>简要结果:命中 / 未找到 / 失败原因摘要。</summary>
public string Result { get; init; } = "";
public int MatchCount { get; init; }
public double BestScore { get; init; }
public double TimeMs { get; init; }
public string? ErrorDetail { get; init; }
public IReadOnlyList<TemplateMatchHitDto> Hits { get; init; } = Array.Empty<TemplateMatchHitDto>();
}
/// <summary>
/// 模板匹配批量测试:扫描文件夹、逐张匹配(与助手当前参数一致)、在主视口打开选中结果。
/// </summary>
public class TemplateMatchBatchViewModel : BindableBase, IDisposable
{
private static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TemplateMatchBatchViewModel>();
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".bmp", ".png", ".jpg", ".jpeg", ".tif", ".tiff"
};
private readonly TemplateMatchAssistantViewModel _assistant;
private readonly IEventAggregator _eventAggregator;
private readonly IMainViewportService _mainViewportService;
private readonly List<string> _imagePaths = new();
private CancellationTokenSource? _batchCts;
private bool _disposed;
private bool _isRunning;
private string _folderPath = "";
private string _batchStatusText = "请选择文件夹后点击「开始批量匹配」。";
private TemplateMatchBatchRow? _selectedRow;
private int _imageFileCount;
public TemplateMatchBatchViewModel(
TemplateMatchAssistantViewModel assistant,
IEventAggregator eventAggregator,
IMainViewportService mainViewportService)
{
_assistant = assistant ?? throw new ArgumentNullException(nameof(assistant));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_assistant.PropertyChanged += OnAssistantPropertyChanged;
PickFolderCommand = new DelegateCommand(ExecutePickFolder);
StartBatchCommand = new DelegateCommand(ExecuteStartBatch, CanStartBatch);
StopBatchCommand = new DelegateCommand(ExecuteStopBatch, () => _isRunning);
OpenSelectedInMainViewportCommand = new DelegateCommand(ExecuteOpenSelectedInMainViewport, () => SelectedRow != null);
}
private void OnAssistantPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TemplateMatchAssistantViewModel.IsModelReady))
StartBatchCommand.RaiseCanExecuteChanged();
}
public ObservableCollection<TemplateMatchBatchRow> Rows { get; } = new();
public DelegateCommand PickFolderCommand { get; }
public DelegateCommand StartBatchCommand { get; }
public DelegateCommand StopBatchCommand { get; }
public DelegateCommand OpenSelectedInMainViewportCommand { get; }
public string FolderPath
{
get => _folderPath;
private set => SetProperty(ref _folderPath, value);
}
public string BatchStatusText
{
get => _batchStatusText;
private set => SetProperty(ref _batchStatusText, value);
}
public int ImageFileCount
{
get => _imageFileCount;
private set => SetProperty(ref _imageFileCount, value);
}
public bool IsRunning
{
get => _isRunning;
private set
{
if (SetProperty(ref _isRunning, value))
{
StartBatchCommand.RaiseCanExecuteChanged();
StopBatchCommand.RaiseCanExecuteChanged();
}
}
}
public TemplateMatchBatchRow? SelectedRow
{
get => _selectedRow;
set
{
if (SetProperty(ref _selectedRow, value))
OpenSelectedInMainViewportCommand.RaiseCanExecuteChanged();
}
}
/// <summary>供宿主窗口 DataGrid 双击调用。</summary>
public void OpenSelectedFromDoubleClick() => OpenSelectedInMainViewportCommand.Execute();
private bool CanStartBatch() =>
!_isRunning && _imagePaths.Count > 0 && _assistant.IsModelReady;
private void ExecutePickFolder()
{
var dlg = new OpenFolderDialog
{
Title = "选择待测试图像所在文件夹(仅当前目录,不含子文件夹)",
InitialDirectory = Directory.Exists(_folderPath) ? _folderPath : Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)
};
if (dlg.ShowDialog() != true)
return;
ScanFolder(dlg.FolderName);
}
private void ScanFolder(string folder)
{
_imagePaths.Clear();
if (!Directory.Exists(folder))
{
FolderPath = "";
ImageFileCount = 0;
BatchStatusText = "文件夹不存在。";
StartBatchCommand.RaiseCanExecuteChanged();
return;
}
foreach (var path in Directory.EnumerateFiles(folder, "*.*", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
if (ImageExtensions.Contains(Path.GetExtension(path)))
_imagePaths.Add(path);
}
FolderPath = folder;
ImageFileCount = _imagePaths.Count;
BatchStatusText = ImageFileCount == 0
? "该文件夹下没有支持的图像文件(bmp/png/jpg/tif…)。"
: $"已扫描 {ImageFileCount} 个图像文件,可开始批量匹配。";
StartBatchCommand.RaiseCanExecuteChanged();
}
private async void ExecuteStartBatch()
{
if (!CanStartBatch()) return;
_assistant.ClearTemplateLearningRoiOnViewport();
Rows.Clear();
IsRunning = true;
_batchCts = new CancellationTokenSource();
var token = _batchCts.Token;
int total = _imagePaths.Count;
int index = 0;
try
{
foreach (var path in _imagePaths)
{
token.ThrowIfCancellationRequested();
index++;
BatchStatusText = $"正在处理 {index}/{total}{Path.GetFileName(path)}";
var row = await Task.Run(() => ProcessOneFile(path), token).ConfigureAwait(true);
Rows.Add(row);
}
BatchStatusText = $"完成,共处理 {Rows.Count} 张。";
}
catch (OperationCanceledException)
{
BatchStatusText = $"已停止(已处理 {Rows.Count}/{total} 张)。";
}
catch (Exception ex)
{
Log.Error(ex, "Batch template match failed");
BatchStatusText = $"批量过程异常:{ex.Message}";
}
finally
{
IsRunning = false;
_batchCts?.Dispose();
_batchCts = null;
}
}
private void ExecuteStopBatch() => _batchCts?.Cancel();
private TemplateMatchBatchRow ProcessOneFile(string path)
{
var fileName = Path.GetFileName(path);
try
{
using var mat = CvInvoke.Imread(path, ImreadModes.Grayscale);
if (mat == null || mat.IsEmpty)
{
return new TemplateMatchBatchRow
{
FileName = fileName,
FullPath = path,
Result = "无法读取",
ErrorDetail = "Imread 为空或失败"
};
}
using var gray = mat.ToImage<Gray, byte>();
if (!_assistant.TryMatchGrayImage(gray, out var hits, out var t, out var err))
{
return new TemplateMatchBatchRow
{
FileName = fileName,
FullPath = path,
Result = "失败",
ErrorDetail = err,
TimeMs = t
};
}
int c = hits.Count;
double best = c == 0 ? 0 : hits.Max(h => h.Score);
return new TemplateMatchBatchRow
{
FileName = fileName,
FullPath = path,
Result = c > 0 ? "命中" : "未找到",
MatchCount = c,
BestScore = best,
TimeMs = t,
Hits = CloneHits(hits)
};
}
catch (Exception ex)
{
Log.Warning(ex, "ProcessOneFile: {Path}", path);
return new TemplateMatchBatchRow
{
FileName = fileName,
FullPath = path,
Result = "异常",
ErrorDetail = ex.Message
};
}
}
private static List<TemplateMatchHitDto> CloneHits(IReadOnlyList<TemplateMatchHitDto> src)
{
var list = new List<TemplateMatchHitDto>(src.Count);
foreach (var h in src)
{
list.Add(new TemplateMatchHitDto
{
CenterX = h.CenterX,
CenterY = h.CenterY,
Angle = h.Angle,
Score = h.Score,
LtX = h.LtX, LtY = h.LtY,
RtX = h.RtX, RtY = h.RtY,
RbX = h.RbX, RbY = h.RbY,
LbX = h.LbX, LbY = h.LbY
});
}
return list;
}
private void ExecuteOpenSelectedInMainViewport()
{
if (SelectedRow == null || string.IsNullOrWhiteSpace(SelectedRow.FullPath) || !File.Exists(SelectedRow.FullPath))
return;
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(SelectedRow.FullPath, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
_mainViewportService.SetManualImage(bitmap, SelectedRow.FullPath);
_eventAggregator.GetEvent<ManualImageLoadedEvent>()
.Publish(new ManualImageLoadedPayload(bitmap, SelectedRow.FullPath));
var hits = CloneHits(SelectedRow.Hits);
_eventAggregator.GetEvent<TemplateMatchPreviewResultEvent>().Publish(
new TemplateMatchPreviewPayload { Hits = hits, MatchTimeMs = SelectedRow.TimeMs });
BatchStatusText = $"已在主视图打开:{SelectedRow.FileName}";
}
catch (Exception ex)
{
Log.Error(ex, "OpenSelectedInMainViewport");
BatchStatusText = $"打开失败:{ex.Message}";
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_assistant.PropertyChanged -= OnAssistantPropertyChanged;
_batchCts?.Cancel();
_batchCts?.Dispose();
_batchCts = null;
}
}
@@ -26,8 +26,10 @@ using XplorePlane.Services.MainViewport;
using XP.ImageProcessing.Processors; using XP.ImageProcessing.Processors;
using XplorePlane.Services.Storage; using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc; using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.ImageProcessing;
using XplorePlane.Views; using XplorePlane.Views;
using XplorePlane.Views.Cnc; using XplorePlane.Views.Cnc;
using XplorePlane.Views.ImageProcessing;
namespace XplorePlane.ViewModels namespace XplorePlane.ViewModels
{ {
@@ -137,6 +139,7 @@ namespace XplorePlane.ViewModels
public DelegateCommand WhiteBackgroundDetectionCommand { get; } public DelegateCommand WhiteBackgroundDetectionCommand { get; }
public DelegateCommand BlackBackgroundDetectionCommand { get; } public DelegateCommand BlackBackgroundDetectionCommand { get; }
public DelegateCommand OpenTemplateMatchAssistantCommand { get; }
public DelegateCommand GrayscaleCommand { get; } public DelegateCommand GrayscaleCommand { get; }
public DelegateCommand SharpenCommand { get; } public DelegateCommand SharpenCommand { get; }
public DelegateCommand EnhanceCommand { get; } public DelegateCommand EnhanceCommand { get; }
@@ -224,6 +227,7 @@ namespace XplorePlane.ViewModels
private Window _settingsWindow; private Window _settingsWindow;
private Window _toolboxWindow; private Window _toolboxWindow;
private Window _raySourceConfigWindow; private Window _raySourceConfigWindow;
private Window _templateMatchAssistantWindow;
private object _imagePanelContent; private object _imagePanelContent;
private GridLength _viewportPanelWidth = new(1, GridUnitType.Star); private GridLength _viewportPanelWidth = new(1, GridUnitType.Star);
private GridLength _imagePanelWidth = new(320); private GridLength _imagePanelWidth = new(320);
@@ -342,6 +346,7 @@ namespace XplorePlane.ViewModels
// 图像处理命令 // 图像处理命令
WhiteBackgroundDetectionCommand = new DelegateCommand(ExecuteWhiteBackgroundDetection); WhiteBackgroundDetectionCommand = new DelegateCommand(ExecuteWhiteBackgroundDetection);
BlackBackgroundDetectionCommand = new DelegateCommand(ExecuteBlackBackgroundDetection); BlackBackgroundDetectionCommand = new DelegateCommand(ExecuteBlackBackgroundDetection);
OpenTemplateMatchAssistantCommand = new DelegateCommand(ExecuteOpenTemplateMatchAssistant);
GrayscaleCommand = new DelegateCommand(ExecuteGrayscale); GrayscaleCommand = new DelegateCommand(ExecuteGrayscale);
SharpenCommand = new DelegateCommand(ExecuteSharpen); SharpenCommand = new DelegateCommand(ExecuteSharpen);
EnhanceCommand = new DelegateCommand(ExecuteEnhance); EnhanceCommand = new DelegateCommand(ExecuteEnhance);
@@ -970,6 +975,46 @@ namespace XplorePlane.ViewModels
StatusMessage = "黑底检测:请在图像上拖拽绘制矩形ROI"; StatusMessage = "黑底检测:请在图像上拖拽绘制矩形ROI";
} }
private void ExecuteOpenTemplateMatchAssistant()
{
try
{
if (!CheckImageLoaded())
{
StatusMessage = "请先加载图像再使用模板助手。";
return;
}
if (_templateMatchAssistantWindow != null)
{
if (_templateMatchAssistantWindow.IsLoaded)
{
_templateMatchAssistantWindow.Activate();
return;
}
_templateMatchAssistantWindow = null;
}
var vm = _containerProvider.Resolve<TemplateMatchAssistantViewModel>();
var w = new TemplateMatchAssistantWindow
{
DataContext = vm,
Owner = Application.Current?.MainWindow
};
w.Closed += (_, _) => { _templateMatchAssistantWindow = null; };
_templateMatchAssistantWindow = w;
w.Show();
_logger.Info("Template match assistant opened.");
StatusMessage = "已打开模板匹配助手";
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to open template match assistant");
StatusMessage = $"打开模板助手失败: {ex.Message}";
}
}
private void OnBlackBackgroundRoiDrawn(System.Windows.Int32Rect roi) => private void OnBlackBackgroundRoiDrawn(System.Windows.Int32Rect roi) =>
RunBackgroundRoiDetection(roi, BackgroundDefectMode.BlackBackground); RunBackgroundRoiDetection(roi, BackgroundDefectMode.BlackBackground);
@@ -0,0 +1,274 @@
<Window x:Class="XplorePlane.Views.ImageProcessing.TemplateMatchAssistantWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="模板匹配助手"
Height="560"
Width="520"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResizeWithGrip">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Text="{Binding StatusMessage}"
TextWrapping="Wrap"
Margin="0,0,0,10"
FontSize="12" />
<TabControl Grid.Row="1">
<TabItem Header="单张与参数">
<Grid Margin="0,8,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<StackPanel>
<Button
Margin="0,0,0,4"
Padding="8,4"
Command="{Binding SelectTemplateRoiCommand}"
Content="框选模板 ROI" />
<Button
Margin="0,0,0,8"
Padding="8,4"
Command="{Binding LearnFromRoiCommand}"
Content="从 ROI 训练模板" />
<Grid Margin="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="132" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="52" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Text="匹配阈值" />
<Slider
Grid.Row="0"
Grid.Column="1"
Margin="6,6,6,6"
Minimum="0"
Maximum="1"
SmallChange="0.01"
LargeChange="0.05"
TickFrequency="0.05"
IsSnapToTickEnabled="False"
AutoToolTipPlacement="TopLeft"
VerticalAlignment="Center"
Value="{Binding MatchThreshold, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="0"
Grid.Column="2"
VerticalAlignment="Center"
TextAlignment="Right"
Text="{Binding MatchThreshold, StringFormat={}{0:F2}}" />
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Text="角度容差 (°)" />
<Slider
Grid.Row="1"
Grid.Column="1"
Margin="6,6,6,6"
Minimum="0"
Maximum="180"
SmallChange="1"
LargeChange="5"
TickFrequency="5"
IsSnapToTickEnabled="True"
AutoToolTipPlacement="TopLeft"
VerticalAlignment="Center"
Value="{Binding ToleranceAngle, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="1"
Grid.Column="2"
VerticalAlignment="Center"
TextAlignment="Right"
Text="{Binding ToleranceAngle, StringFormat={}{0:F0}}" />
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Text="最大匹配数" />
<Slider
Grid.Row="2"
Grid.Column="1"
Margin="6,6,6,6"
Minimum="1"
Maximum="100"
SmallChange="1"
LargeChange="5"
TickFrequency="1"
IsSnapToTickEnabled="True"
AutoToolTipPlacement="TopLeft"
VerticalAlignment="Center"
Value="{Binding MaxMatchCount, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="2"
Grid.Column="2"
VerticalAlignment="Center"
TextAlignment="Right"
Text="{Binding MaxMatchCount, StringFormat={}{0:F0}}" />
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="最大重叠" />
<Slider
Grid.Row="3"
Grid.Column="1"
Margin="6,6,6,6"
Minimum="0"
Maximum="1"
SmallChange="0.05"
LargeChange="0.1"
TickFrequency="0.05"
IsSnapToTickEnabled="True"
AutoToolTipPlacement="TopLeft"
VerticalAlignment="Center"
Value="{Binding MaxOverlap, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="3"
Grid.Column="2"
VerticalAlignment="Center"
TextAlignment="Right"
Text="{Binding MaxOverlap, StringFormat={}{0:F2}}" />
<TextBlock Grid.Row="4" Grid.Column="0" VerticalAlignment="Center" TextWrapping="Wrap" Text="金字塔最小面积" />
<Slider
Grid.Row="4"
Grid.Column="1"
Margin="6,6,6,6"
Minimum="64"
Maximum="4096"
SmallChange="32"
LargeChange="128"
TickFrequency="32"
IsSnapToTickEnabled="True"
AutoToolTipPlacement="TopLeft"
VerticalAlignment="Center"
Value="{Binding MinReduceArea, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Row="4"
Grid.Column="2"
VerticalAlignment="Center"
TextAlignment="Right"
Text="{Binding MinReduceArea, StringFormat={}{0:F0}}" />
<CheckBox
Grid.Row="5"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,8,0,0"
Content="使用 SIMD"
IsChecked="{Binding UseSimd}" />
<CheckBox
Grid.Row="6"
Grid.Column="0"
Grid.ColumnSpan="3"
Margin="0,4,0,0"
Content="亚像素"
IsChecked="{Binding UseSubPixel}" />
</Grid>
</StackPanel>
</ScrollViewer>
<StackPanel
Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,10,0,0">
<Button Margin="0,0,8,0" Padding="10,4" Command="{Binding LoadModelCommand}" Content="加载模型" />
<Button Margin="0,0,8,0" Padding="10,4" Command="{Binding SaveModelCommand}" Content="保存模型" />
<Button Padding="10,4" Command="{Binding RunMatchCommand}" Content="运行匹配" />
</StackPanel>
</Grid>
</TabItem>
<TabItem Header="批量测试">
<Grid Margin="0,8,0,0" DataContext="{Binding Batch}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Text="{Binding BatchStatusText}"
TextWrapping="Wrap"
Margin="0,0,0,8"
FontSize="12" />
<Grid Grid.Row="1" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button
Padding="10,4"
Margin="0,0,8,0"
Content="选择文件夹…"
Command="{Binding PickFolderCommand}" />
<Button
Padding="10,4"
Margin="0,0,8,0"
Content="开始批量匹配"
Command="{Binding StartBatchCommand}" />
<Button
Padding="10,4"
Margin="0,0,8,0"
Content="停止"
Command="{Binding StopBatchCommand}" />
<Button
Padding="10,4"
Content="在主视图打开所选"
Command="{Binding OpenSelectedInMainViewportCommand}" />
</StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="8,0,0,0">
<TextBlock Foreground="Gray" FontSize="11" Text="{Binding ImageFileCount, StringFormat={}{0} 个图像文件}" />
<TextBlock
Foreground="Gray"
FontSize="11"
Text="{Binding FolderPath}"
TextTrimming="CharacterEllipsis"
MaxWidth="220" />
</StackPanel>
</Grid>
<DataGrid
x:Name="BatchResultGrid"
Grid.Row="2"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow, Mode=TwoWay}"
MouseDoubleClick="BatchResultGrid_OnMouseDoubleClick">
<DataGrid.Columns>
<DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*" MinWidth="120" />
<DataGridTextColumn Header="结果" Binding="{Binding Result}" Width="72" />
<DataGridTextColumn Header="匹配数" Binding="{Binding MatchCount}" Width="64" />
<DataGridTextColumn Header="最佳分数" Binding="{Binding BestScore, StringFormat=F3}" Width="80" />
<DataGridTextColumn Header="耗时(ms)" Binding="{Binding TimeMs, StringFormat=F1}" Width="80" />
<DataGridTextColumn Header="备注" Binding="{Binding ErrorDetail}" Width="2*" MinWidth="100" />
</DataGrid.Columns>
</DataGrid>
<TextBlock
Grid.Row="3"
Margin="0,8,0,0"
FontSize="11"
Foreground="Gray"
TextWrapping="Wrap"
Text="参数与「单张与参数」选项卡一致;仅扫描所选文件夹当前层级。双击一行可在主视图打开该图并显示匹配框。" />
</Grid>
</TabItem>
</TabControl>
</Grid>
</Window>
@@ -0,0 +1,28 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using XplorePlane.ViewModels.ImageProcessing;
namespace XplorePlane.Views.ImageProcessing;
public partial class TemplateMatchAssistantWindow : Window
{
public TemplateMatchAssistantWindow()
{
InitializeComponent();
}
private void BatchResultGrid_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is DataGrid g && g.DataContext is TemplateMatchBatchViewModel vm)
vm.OpenSelectedFromDoubleClick();
}
protected override void OnClosed(EventArgs e)
{
if (DataContext is IDisposable d)
d.Dispose();
base.OnClosed(e);
}
}
+7
View File
@@ -192,6 +192,13 @@
Size="Medium" Size="Medium"
SmallImage="/Assets/Icons/film-darken.png" SmallImage="/Assets/Icons/film-darken.png"
Text="黑底检测" /> Text="黑底检测" />
<telerik:RadRibbonButton
telerik:ScreenTip.Description="框选模板、调参并在当前图像上试跑旋转模板匹配"
telerik:ScreenTip.Title="模板匹配助手"
Command="{Binding OpenTemplateMatchAssistantCommand}"
Size="Medium"
SmallImage="/Assets/Icons/dynamic-range.png"
Text="模板助手" />
</StackPanel> </StackPanel>
<StackPanel> <StackPanel>
<telerik:RadRibbonButton <telerik:RadRibbonButton
@@ -166,6 +166,24 @@ namespace XplorePlane.Views
{ {
RenderBackgroundDefectResult(payload.RoiRect, payload.Detections, isBlackBackground: true); RenderBackgroundDefectResult(payload.RoiRect, payload.Detections, isBlackBackground: true);
}, Prism.Events.ThreadOption.UIThread); }, Prism.Events.ThreadOption.UIThread);
ea2?.GetEvent<TemplateMatchEnterRoiModeEvent>().Subscribe(() =>
{
_bgDefectDrawing = false;
_bgDefectRoiMode = BackgroundDefectRoiMode.TemplateAssistant;
RegisterBackgroundDefectRoiMouseHandlers();
SetStatus("模板助手:请在图像上拖拽框选模板区域");
}, Prism.Events.ThreadOption.UIThread);
ea2?.GetEvent<TemplateMatchPreviewResultEvent>().Subscribe(payload =>
{
RenderTemplateMatchPreview(payload);
}, Prism.Events.ThreadOption.UIThread);
ea2?.GetEvent<TemplateMatchClearRoiOverlayEvent>().Subscribe(() =>
{
RemoveTemplateAssistantPersistRoi();
}, Prism.Events.ThreadOption.UIThread);
} }
catch { } catch { }
} }
@@ -365,15 +383,21 @@ namespace XplorePlane.Views
_bgDefectPreview = null; _bgDefectPreview = null;
} }
ClearBackgroundDefectOverlays(canvas); ClearBackgroundDefectOverlays(canvas);
ClearTemplateMatchOverlays(canvas);
RemoveTemplateAssistantPersistRoi();
} }
else else
{
_bgDefectOverlays.Clear(); _bgDefectOverlays.Clear();
_tmMatchOverlays.Clear();
RemoveTemplateAssistantPersistRoi();
}
_bgDefectDrawing = false; _bgDefectDrawing = false;
_bgDefectRoiMode = BackgroundDefectRoiMode.None; _bgDefectRoiMode = BackgroundDefectRoiMode.None;
try { RoiCanvas.ReleaseMouseCapture(); } catch { /* 未捕获则无影响 */ } try { RoiCanvas.ReleaseMouseCapture(); } catch { /* 未捕获则无影响 */ }
SetStatus("已清除所有测量白底/黑底检测结果"); SetStatus("已清除所有测量白底/黑底检测、模板匹配试跑叠加及模板助手 ROI");
} }
private void SaveOriginalImage_Click(object sender, RoutedEventArgs e) private void SaveOriginalImage_Click(object sender, RoutedEventArgs e)
@@ -432,7 +456,8 @@ namespace XplorePlane.Views
{ {
None, None,
WhiteBackground, WhiteBackground,
BlackBackground BlackBackground,
TemplateAssistant
} }
private BackgroundDefectRoiMode _bgDefectRoiMode; private BackgroundDefectRoiMode _bgDefectRoiMode;
@@ -440,6 +465,8 @@ namespace XplorePlane.Views
private System.Windows.Point _bgDefectStart; private System.Windows.Point _bgDefectStart;
private System.Windows.Shapes.Rectangle _bgDefectPreview; private System.Windows.Shapes.Rectangle _bgDefectPreview;
private readonly System.Collections.Generic.List<System.Windows.UIElement> _bgDefectOverlays = new(); private readonly System.Collections.Generic.List<System.Windows.UIElement> _bgDefectOverlays = new();
private readonly System.Collections.Generic.List<System.Windows.UIElement> _tmMatchOverlays = new();
private System.Windows.Shapes.Rectangle _templateAssistantRoiPersist;
private bool _bgDefectMouseHandlersRegistered; private bool _bgDefectMouseHandlersRegistered;
private void RegisterBackgroundDefectRoiMouseHandlers() private void RegisterBackgroundDefectRoiMouseHandlers()
@@ -525,6 +552,25 @@ namespace XplorePlane.Views
if (w < 10 || h < 10) return; // 太小忽略 if (w < 10 || h < 10) return; // 太小忽略
// 模板助手:在画布上保留 ROI 矩形(与试跑匹配叠加分开管理)
if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
{
RemoveTemplateAssistantPersistRoi();
_templateAssistantRoiPersist = new System.Windows.Shapes.Rectangle
{
Stroke = System.Windows.Media.Brushes.DeepSkyBlue,
StrokeThickness = 1.5,
StrokeDashArray = new System.Windows.Media.DoubleCollection { 4, 2 },
Fill = System.Windows.Media.Brushes.Transparent,
Width = Math.Max(1, w),
Height = Math.Max(1, h),
IsHitTestVisible = false
};
System.Windows.Controls.Canvas.SetLeft(_templateAssistantRoiPersist, x);
System.Windows.Controls.Canvas.SetTop(_templateAssistantRoiPersist, y);
canvas.Children.Add(_templateAssistantRoiPersist);
}
// 发布ROI绘制完成事件 // 发布ROI绘制完成事件
try try
{ {
@@ -534,12 +580,106 @@ namespace XplorePlane.Views
ea?.GetEvent<WhiteBackgroundRoiDrawnEvent>().Publish(rect); ea?.GetEvent<WhiteBackgroundRoiDrawnEvent>().Publish(rect);
else if (completedMode == BackgroundDefectRoiMode.BlackBackground) else if (completedMode == BackgroundDefectRoiMode.BlackBackground)
ea?.GetEvent<BlackBackgroundRoiDrawnEvent>().Publish(rect); ea?.GetEvent<BlackBackgroundRoiDrawnEvent>().Publish(rect);
else if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
ea?.GetEvent<TemplateMatchRoiDrawnEvent>().Publish(rect);
} }
catch { } catch { }
e.Handled = true; e.Handled = true;
} }
private void ClearTemplateMatchOverlays(System.Windows.Controls.Canvas canvas)
{
if (canvas != null)
{
foreach (var el in _tmMatchOverlays)
canvas.Children.Remove(el);
}
_tmMatchOverlays.Clear();
}
private void RemoveTemplateAssistantPersistRoi()
{
if (_templateAssistantRoiPersist == null) return;
var rect = _templateAssistantRoiPersist;
_templateAssistantRoiPersist = null;
if (VisualTreeHelper.GetParent(rect) is Panel p)
p.Children.Remove(rect);
}
private void RenderTemplateMatchPreview(TemplateMatchPreviewPayload payload)
{
var canvas = FindChildByName<System.Windows.Controls.Canvas>(RoiCanvas, "mainCanvas");
if (canvas == null) return;
ClearTemplateMatchOverlays(canvas);
if (payload?.Hits == null || payload.Hits.Count == 0)
return;
var stroke = new SolidColorBrush(Color.FromRgb(255, 140, 0));
stroke.Freeze();
const int crossHalf = 8;
foreach (var h in payload.Hits)
{
var poly = new System.Windows.Shapes.Polygon
{
Stroke = stroke,
StrokeThickness = 2,
Fill = Brushes.Transparent,
IsHitTestVisible = false,
Points = new PointCollection
{
new System.Windows.Point(h.LtX, h.LtY),
new System.Windows.Point(h.RtX, h.RtY),
new System.Windows.Point(h.RbX, h.RbY),
new System.Windows.Point(h.LbX, h.LbY)
}
};
canvas.Children.Add(poly);
_tmMatchOverlays.Add(poly);
var cx = h.CenterX;
var cy = h.CenterY;
var hLine = new System.Windows.Shapes.Line
{
X1 = cx - crossHalf,
Y1 = cy,
X2 = cx + crossHalf,
Y2 = cy,
Stroke = stroke,
StrokeThickness = 1.5,
IsHitTestVisible = false
};
var vLine = new System.Windows.Shapes.Line
{
X1 = cx,
Y1 = cy - crossHalf,
X2 = cx,
Y2 = cy + crossHalf,
Stroke = stroke,
StrokeThickness = 1.5,
IsHitTestVisible = false
};
canvas.Children.Add(hLine);
canvas.Children.Add(vLine);
_tmMatchOverlays.Add(hLine);
_tmMatchOverlays.Add(vLine);
var tb = new System.Windows.Controls.TextBlock
{
Text = $"{h.Score:F2}",
Foreground = stroke,
FontSize = 10,
IsHitTestVisible = false
};
System.Windows.Controls.Canvas.SetLeft(tb, cx + crossHalf + 2);
System.Windows.Controls.Canvas.SetTop(tb, cy - 8);
canvas.Children.Add(tb);
_tmMatchOverlays.Add(tb);
}
}
private void RenderBackgroundDefectResult( private void RenderBackgroundDefectResult(
System.Drawing.Rectangle roiRect, System.Drawing.Rectangle roiRect,
System.Collections.Generic.IReadOnlyList<BackgroundDefectDetectionItem> detections, System.Collections.Generic.IReadOnlyList<BackgroundDefectDetectionItem> detections,