From e0eec42a2f6493cd57babcf5eb8f527f95452b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Fri, 15 May 2026 10:42:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E6=9D=BF=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=AA=97=E5=8F=A3=E4=B8=8E=E4=B8=BB=E8=A7=86?= =?UTF-8?q?=E5=8F=A3=20ROI=20=E6=B8=85=E9=99=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增模板助手/批量测试窗口、事件与 ViewModel,主窗口入口与 App 注册 - 运行匹配或批量测试前发布清除事件;视口通过 VisualTreeHelper 从父 Panel 移除持久虚线 ROI,避免 FindChild 失败时框残留 - TemplateMatchNative 等相关调整 Co-authored-by: Cursor --- .../定位识别/TemplateMatchNative.cs | 34 +- XplorePlane/App.xaml.cs | 2 + .../Events/TemplateMatchAssistantEvents.cs | 47 ++ .../TemplateMatchAssistantViewModel.cs | 507 ++++++++++++++++++ .../TemplateMatchBatchViewModel.cs | 340 ++++++++++++ XplorePlane/ViewModels/Main/MainViewModel.cs | 45 ++ .../TemplateMatchAssistantWindow.xaml | 274 ++++++++++ .../TemplateMatchAssistantWindow.xaml.cs | 28 + XplorePlane/Views/Main/MainWindow.xaml | 7 + .../Views/Main/ViewportPanelView.xaml.cs | 144 ++++- 10 files changed, 1424 insertions(+), 4 deletions(-) create mode 100644 XplorePlane/Events/TemplateMatchAssistantEvents.cs create mode 100644 XplorePlane/ViewModels/ImageProcessing/TemplateMatchAssistantViewModel.cs create mode 100644 XplorePlane/ViewModels/ImageProcessing/TemplateMatchBatchViewModel.cs create mode 100644 XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml create mode 100644 XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml.cs diff --git a/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs b/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs index 35e0364..043315d 100644 --- a/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs +++ b/XP.ImageProcessing.Processors/定位识别/TemplateMatchNative.cs @@ -30,6 +30,12 @@ public struct TM_Params /// 是否亚像素估计 (1=是, 0=否) public int UseSubPixel; + /// + /// 开启亚像素且角度容差绝对值超过该值时,托管封装会在调用原生库前关闭亚像素, + /// 以避免部分版本 TemplateMatchLib 在 Debug 下出现 vector 越界断言。 + /// + public const double SubPixelAngleSafetyLimitDegrees = 90.0; + /// /// 创建默认参数 /// @@ -168,9 +174,33 @@ public sealed class TemplateMatcherHandle : IDisposable public TM_Result[] Match(IntPtr srcData, int srcWidth, int srcHeight, int srcStep, TM_Params param) { 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, - ref param, results, param.MaxCount); + ref p, results, p.MaxCount); if (count <= 0) return Array.Empty(); diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 73eb41c..258a6c0 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -47,6 +47,7 @@ using XplorePlane.Services.Recipe; using XplorePlane.Services.Storage; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; +using XplorePlane.ViewModels.ImageProcessing; using XplorePlane.Views; using XplorePlane.Views.Cnc; @@ -422,6 +423,7 @@ namespace XplorePlane // 注册流水线 ViewModel(每次解析创建新实例) containerRegistry.Register(); containerRegistry.Register(); + containerRegistry.Register(); // 注册硬件库的 ViewModel(供 ViewModelLocator 自动装配) containerRegistry.Register(); diff --git a/XplorePlane/Events/TemplateMatchAssistantEvents.cs b/XplorePlane/Events/TemplateMatchAssistantEvents.cs new file mode 100644 index 0000000..f83747e --- /dev/null +++ b/XplorePlane/Events/TemplateMatchAssistantEvents.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Windows; +using Prism.Events; + +namespace XplorePlane.Events; + +/// +/// 进入「在视口上框选模板 ROI」模式(与主画布 Preview 鼠标逻辑配合)。 +/// +public class TemplateMatchEnterRoiModeEvent : PubSubEvent { } + +/// +/// 模板 ROI 框选完成(图像/画布像素坐标,与白底检测 ROI 约定一致)。仅表示区域已确定,不表示已训练。 +/// +public class TemplateMatchRoiDrawnEvent : PubSubEvent { } + +/// +/// 清除视口上的模板助手持久 ROI 框(例如加载模型后或重置时)。 +/// +public class TemplateMatchClearRoiOverlayEvent : PubSubEvent { } + +/// +/// 单次模板匹配试跑结果,供主视图叠加层绘制。 +/// +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 Hits { get; set; } = new(); + public double MatchTimeMs { get; set; } +} + +public class TemplateMatchPreviewResultEvent : PubSubEvent { } diff --git a/XplorePlane/ViewModels/ImageProcessing/TemplateMatchAssistantViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchAssistantViewModel.cs new file mode 100644 index 0000000..2cb5579 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchAssistantViewModel.cs @@ -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; + +/// +/// 旋转模板匹配助手:框选 ROI、从 ROI 训练、参数、加载/保存模型、在当页试匹配;批量测试见 。 +/// +public class TemplateMatchAssistantViewModel : BindableBase, IDisposable +{ + private static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); + + 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()); + + _roiDrawnToken = _eventAggregator.GetEvent() + .Subscribe(OnTemplateRoiDrawn, ThreadOption.UIThread); + } + + /// 批量测试子页(与助手共用同一模型与参数)。 + 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)); + } + + /// 最大匹配数(滑块 1~100,运行匹配时取整)。 + 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)); + } + + /// 金字塔最小面积(滑块 64~4096,运行匹配时取整)。 + 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(); + } + } + } + + /// 是否已有框选完成的模板 ROI(与是否已训练无关)。 + public bool HasPendingTemplateRoi + { + get => _hasPendingTemplateRoi; + private set + { + if (SetProperty(ref _hasPendingTemplateRoi, value)) + LearnFromRoiCommand.RaiseCanExecuteChanged(); + } + } + + /// + /// 清除主视图上框选的模板学习 ROI 叠加,并重置待训练 ROI 状态(运行匹配或开始批量测试前调用)。 + /// + public void ClearTemplateLearningRoiOnViewport() + { + _eventAggregator.GetEvent().Publish(); + HasPendingTemplateRoi = false; + } + + private void ExecuteSelectTemplateRoi() + { + _eventAggregator.GetEvent().Publish(); + StatusMessage = "请在主视图图像上拖拽框选模板区域;框选完成后 ROI 会保留在图上,再点击「从 ROI 训练模板」。"; + } + + private void OnTemplateRoiDrawn(Int32Rect roi) + { + var viewportVm = _containerProvider.Resolve(); + 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(); + 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(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().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(); + var imageSource = viewportVm?.ImageSource as BitmapSource; + if (imageSource == null) + { + StatusMessage = "当前无主视图图像。"; + return; + } + + using Image? 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 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().Publish(new TemplateMatchPreviewPayload()); + return; + } + + var hits = new List(hitsReadonly); + + _eventAggregator.GetEvent().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().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 + }; + + /// + /// 使用当前助手参数对灰度图做模板匹配(与单张「运行匹配」一致)。供批量测试在后台线程调用,内部已加锁。 + /// + public bool TryMatchGrayImage(Image fullImage, out IReadOnlyList hits, + out double matchTimeMs, out string? errorMessage) + { + hits = Array.Empty(); + 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(); + 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? 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(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().Unsubscribe(_roiDrawnToken); + _roiDrawnToken = null; + } + + _batch.Dispose(); + + lock (_matcherLock) + { + _matcher?.Dispose(); + _matcher = null; + } + } +} diff --git a/XplorePlane/ViewModels/ImageProcessing/TemplateMatchBatchViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchBatchViewModel.cs new file mode 100644 index 0000000..fa70d43 --- /dev/null +++ b/XplorePlane/ViewModels/ImageProcessing/TemplateMatchBatchViewModel.cs @@ -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; + +/// 单张批量匹配结果行(绑定到 DataGrid)。 +public sealed class TemplateMatchBatchRow +{ + public string FileName { get; init; } = ""; + public string FullPath { get; init; } = ""; + /// 简要结果:命中 / 未找到 / 失败原因摘要。 + 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 Hits { get; init; } = Array.Empty(); +} + +/// +/// 模板匹配批量测试:扫描文件夹、逐张匹配(与助手当前参数一致)、在主视口打开选中结果。 +/// +public class TemplateMatchBatchViewModel : BindableBase, IDisposable +{ + private static readonly Serilog.ILogger Log = Serilog.Log.ForContext(); + private static readonly HashSet 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 _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 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(); + } + } + + /// 供宿主窗口 DataGrid 双击调用。 + 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(); + 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 CloneHits(IReadOnlyList src) + { + var list = new List(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() + .Publish(new ManualImageLoadedPayload(bitmap, SelectedRow.FullPath)); + + var hits = CloneHits(SelectedRow.Hits); + _eventAggregator.GetEvent().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; + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 965a9b6..e781ed2 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -26,8 +26,10 @@ using XplorePlane.Services.MainViewport; using XP.ImageProcessing.Processors; using XplorePlane.Services.Storage; using XplorePlane.ViewModels.Cnc; +using XplorePlane.ViewModels.ImageProcessing; using XplorePlane.Views; using XplorePlane.Views.Cnc; +using XplorePlane.Views.ImageProcessing; namespace XplorePlane.ViewModels { @@ -137,6 +139,7 @@ namespace XplorePlane.ViewModels public DelegateCommand WhiteBackgroundDetectionCommand { get; } public DelegateCommand BlackBackgroundDetectionCommand { get; } + public DelegateCommand OpenTemplateMatchAssistantCommand { get; } public DelegateCommand GrayscaleCommand { get; } public DelegateCommand SharpenCommand { get; } public DelegateCommand EnhanceCommand { get; } @@ -224,6 +227,7 @@ namespace XplorePlane.ViewModels private Window _settingsWindow; private Window _toolboxWindow; private Window _raySourceConfigWindow; + private Window _templateMatchAssistantWindow; private object _imagePanelContent; private GridLength _viewportPanelWidth = new(1, GridUnitType.Star); private GridLength _imagePanelWidth = new(320); @@ -342,6 +346,7 @@ namespace XplorePlane.ViewModels // 图像处理命令 WhiteBackgroundDetectionCommand = new DelegateCommand(ExecuteWhiteBackgroundDetection); BlackBackgroundDetectionCommand = new DelegateCommand(ExecuteBlackBackgroundDetection); + OpenTemplateMatchAssistantCommand = new DelegateCommand(ExecuteOpenTemplateMatchAssistant); GrayscaleCommand = new DelegateCommand(ExecuteGrayscale); SharpenCommand = new DelegateCommand(ExecuteSharpen); EnhanceCommand = new DelegateCommand(ExecuteEnhance); @@ -970,6 +975,46 @@ namespace XplorePlane.ViewModels 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(); + 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) => RunBackgroundRoiDetection(roi, BackgroundDefectMode.BlackBackground); diff --git a/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml b/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml new file mode 100644 index 0000000..026f037 --- /dev/null +++ b/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + +