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