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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml.cs b/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml.cs
new file mode 100644
index 0000000..3a84158
--- /dev/null
+++ b/XplorePlane/Views/ImageProcessing/TemplateMatchAssistantWindow.xaml.cs
@@ -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);
+ }
+}
diff --git a/XplorePlane/Views/Main/MainWindow.xaml b/XplorePlane/Views/Main/MainWindow.xaml
index c16b92a..b63c79e 100644
--- a/XplorePlane/Views/Main/MainWindow.xaml
+++ b/XplorePlane/Views/Main/MainWindow.xaml
@@ -192,6 +192,13 @@
Size="Medium"
SmallImage="/Assets/Icons/film-darken.png"
Text="黑底检测" />
+
().Subscribe(() =>
+ {
+ _bgDefectDrawing = false;
+ _bgDefectRoiMode = BackgroundDefectRoiMode.TemplateAssistant;
+ RegisterBackgroundDefectRoiMouseHandlers();
+ SetStatus("模板助手:请在图像上拖拽框选模板区域");
+ }, Prism.Events.ThreadOption.UIThread);
+
+ ea2?.GetEvent().Subscribe(payload =>
+ {
+ RenderTemplateMatchPreview(payload);
+ }, Prism.Events.ThreadOption.UIThread);
+
+ ea2?.GetEvent().Subscribe(() =>
+ {
+ RemoveTemplateAssistantPersistRoi();
+ }, Prism.Events.ThreadOption.UIThread);
}
catch { }
}
@@ -365,15 +383,21 @@ namespace XplorePlane.Views
_bgDefectPreview = null;
}
ClearBackgroundDefectOverlays(canvas);
+ ClearTemplateMatchOverlays(canvas);
+ RemoveTemplateAssistantPersistRoi();
}
else
+ {
_bgDefectOverlays.Clear();
+ _tmMatchOverlays.Clear();
+ RemoveTemplateAssistantPersistRoi();
+ }
_bgDefectDrawing = false;
_bgDefectRoiMode = BackgroundDefectRoiMode.None;
try { RoiCanvas.ReleaseMouseCapture(); } catch { /* 未捕获则无影响 */ }
- SetStatus("已清除所有测量及白底/黑底检测结果");
+ SetStatus("已清除所有测量、白底/黑底检测、模板匹配试跑叠加及模板助手 ROI");
}
private void SaveOriginalImage_Click(object sender, RoutedEventArgs e)
@@ -432,7 +456,8 @@ namespace XplorePlane.Views
{
None,
WhiteBackground,
- BlackBackground
+ BlackBackground,
+ TemplateAssistant
}
private BackgroundDefectRoiMode _bgDefectRoiMode;
@@ -440,6 +465,8 @@ namespace XplorePlane.Views
private System.Windows.Point _bgDefectStart;
private System.Windows.Shapes.Rectangle _bgDefectPreview;
private readonly System.Collections.Generic.List _bgDefectOverlays = new();
+ private readonly System.Collections.Generic.List _tmMatchOverlays = new();
+ private System.Windows.Shapes.Rectangle _templateAssistantRoiPersist;
private bool _bgDefectMouseHandlersRegistered;
private void RegisterBackgroundDefectRoiMouseHandlers()
@@ -525,6 +552,25 @@ namespace XplorePlane.Views
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绘制完成事件
try
{
@@ -534,12 +580,106 @@ namespace XplorePlane.Views
ea?.GetEvent().Publish(rect);
else if (completedMode == BackgroundDefectRoiMode.BlackBackground)
ea?.GetEvent().Publish(rect);
+ else if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
+ ea?.GetEvent().Publish(rect);
}
catch { }
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(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(
System.Drawing.Rectangle roiRect,
System.Collections.Generic.IReadOnlyList detections,