Files
XplorePlane/XplorePlane/ViewModels/ImageProcessing/TemplateMatchBatchViewModel.cs
T
李伟 e0eec42a2f feat: 模板匹配助手窗口与主视口 ROI 清除逻辑
- 新增模板助手/批量测试窗口、事件与 ViewModel,主窗口入口与 App 注册

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

- TemplateMatchNative 等相关调整

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 10:42:20 +08:00

341 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}