feat: 模板匹配助手窗口与主视口 ROI 清除逻辑
- 新增模板助手/批量测试窗口、事件与 ViewModel,主窗口入口与 App 注册 - 运行匹配或批量测试前发布清除事件;视口通过 VisualTreeHelper 从父 Panel 移除持久虚线 ROI,避免 FindChild 失败时框残留 - TemplateMatchNative 等相关调整 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,12 @@ public struct TM_Params
|
||||
/// <summary>是否亚像素估计 (1=是, 0=否)</summary>
|
||||
public int UseSubPixel;
|
||||
|
||||
/// <summary>
|
||||
/// 开启亚像素且角度容差绝对值超过该值时,托管封装会在调用原生库前关闭亚像素,
|
||||
/// 以避免部分版本 TemplateMatchLib 在 Debug 下出现 vector 越界断言。
|
||||
/// </summary>
|
||||
public const double SubPixelAngleSafetyLimitDegrees = 90.0;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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<TM_Result>();
|
||||
|
||||
@@ -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<PipelineEditorViewModel>();
|
||||
containerRegistry.Register<OperatorToolboxViewModel>();
|
||||
containerRegistry.Register<TemplateMatchAssistantViewModel>();
|
||||
|
||||
// 注册硬件库的 ViewModel(供 ViewModelLocator 自动装配)
|
||||
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 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<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) =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,13 @@
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/film-darken.png"
|
||||
Text="黑底检测" />
|
||||
<telerik:RadRibbonButton
|
||||
telerik:ScreenTip.Description="框选模板、调参并在当前图像上试跑旋转模板匹配"
|
||||
telerik:ScreenTip.Title="模板匹配助手"
|
||||
Command="{Binding OpenTemplateMatchAssistantCommand}"
|
||||
Size="Medium"
|
||||
SmallImage="/Assets/Icons/dynamic-range.png"
|
||||
Text="模板助手" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<telerik:RadRibbonButton
|
||||
|
||||
@@ -166,6 +166,24 @@ namespace XplorePlane.Views
|
||||
{
|
||||
RenderBackgroundDefectResult(payload.RoiRect, payload.Detections, isBlackBackground: true);
|
||||
}, 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 { }
|
||||
}
|
||||
@@ -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<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 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<WhiteBackgroundRoiDrawnEvent>().Publish(rect);
|
||||
else if (completedMode == BackgroundDefectRoiMode.BlackBackground)
|
||||
ea?.GetEvent<BlackBackgroundRoiDrawnEvent>().Publish(rect);
|
||||
else if (completedMode == BackgroundDefectRoiMode.TemplateAssistant)
|
||||
ea?.GetEvent<TemplateMatchRoiDrawnEvent>().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<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(
|
||||
System.Drawing.Rectangle roiRect,
|
||||
System.Collections.Generic.IReadOnlyList<BackgroundDefectDetectionItem> detections,
|
||||
|
||||
Reference in New Issue
Block a user