e0eec42a2f
- 新增模板助手/批量测试窗口、事件与 ViewModel,主窗口入口与 App 注册 - 运行匹配或批量测试前发布清除事件;视口通过 VisualTreeHelper 从父 Panel 移除持久虚线 ROI,避免 FindChild 失败时框残留 - TemplateMatchNative 等相关调整 Co-authored-by: Cursor <cursoragent@cursor.com>
341 lines
11 KiB
C#
341 lines
11 KiB
C#
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;
|
||
}
|
||
}
|