From 7c7d0b6a20c26996192deea7ab20752e04bf5cd7 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Sat, 14 Mar 2026 23:21:19 +0800 Subject: [PATCH] =?UTF-8?q?#0020=20=E6=B5=AE=E5=8A=A8=E5=9B=BE=E5=83=8F?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=B7=A5=E5=85=B7=E7=AE=B1=E8=B0=83=E7=A0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- XplorePlane.sln | 28 +- XplorePlane/App.xaml.cs | 8 + XplorePlane/Models/PipelineModels.cs | 30 ++ .../Services/IPipelineExecutionService.cs | 20 + .../Services/IPipelinePersistenceService.cs | 13 + .../Services/PipelineExecutionException.cs | 24 ++ .../Services/PipelineExecutionService.cs | 101 +++++ .../Services/PipelinePersistenceService.cs | 111 +++++ XplorePlane/ViewModels/MainViewModel.cs | 8 + .../ViewModels/OperatorToolboxViewModel.cs | 73 ++++ .../ViewModels/PipelineEditorViewModel.cs | 403 ++++++++++++++++++ .../ViewModels/PipelineNodeViewModel.cs | 59 +++ .../ViewModels/ProcessorParameterVM.cs | 35 +- XplorePlane/Views/MainWindow.xaml | 4 + XplorePlane/Views/OperatorToolboxView.xaml | 84 ++++ XplorePlane/Views/OperatorToolboxView.xaml.cs | 19 + XplorePlane/Views/PipelineEditorView.xaml | 221 ++++++++++ XplorePlane/Views/PipelineEditorView.xaml.cs | 37 ++ XplorePlane/Views/PipelineEditorWindow.xaml | 19 + .../Views/PipelineEditorWindow.xaml.cs | 12 + .../XplorePlane/Libs/Hardware/XP.Common.dll | Bin 81920 -> 0 bytes .../XplorePlane/Libs/Hardware/XP.Common.pdb | Bin 41880 -> 0 bytes .../Libs/Hardware/XP.Hardware.RaySource.dll | Bin 93184 -> 0 bytes .../Libs/Hardware/XP.Hardware.RaySource.pdb | Bin 43524 -> 0 bytes .../Hardware/en-US/XP.Common.resources.dll | Bin 10240 -> 0 bytes .../Hardware/zh-CN/XP.Common.resources.dll | Bin 9216 -> 0 bytes .../Hardware/zh-TW/XP.Common.resources.dll | Bin 9728 -> 0 bytes XplorePlane/readme.txt | 4 +- 29 files changed, 1311 insertions(+), 4 deletions(-) create mode 100644 XplorePlane/Models/PipelineModels.cs create mode 100644 XplorePlane/Services/IPipelineExecutionService.cs create mode 100644 XplorePlane/Services/IPipelinePersistenceService.cs create mode 100644 XplorePlane/Services/PipelineExecutionException.cs create mode 100644 XplorePlane/Services/PipelineExecutionService.cs create mode 100644 XplorePlane/Services/PipelinePersistenceService.cs create mode 100644 XplorePlane/ViewModels/OperatorToolboxViewModel.cs create mode 100644 XplorePlane/ViewModels/PipelineEditorViewModel.cs create mode 100644 XplorePlane/ViewModels/PipelineNodeViewModel.cs create mode 100644 XplorePlane/Views/OperatorToolboxView.xaml create mode 100644 XplorePlane/Views/OperatorToolboxView.xaml.cs create mode 100644 XplorePlane/Views/PipelineEditorView.xaml create mode 100644 XplorePlane/Views/PipelineEditorView.xaml.cs create mode 100644 XplorePlane/Views/PipelineEditorWindow.xaml create mode 100644 XplorePlane/Views/PipelineEditorWindow.xaml.cs delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/XP.Common.dll delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/XP.Common.pdb delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/XP.Hardware.RaySource.dll delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/XP.Hardware.RaySource.pdb delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/en-US/XP.Common.resources.dll delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/zh-CN/XP.Common.resources.dll delete mode 100644 XplorePlane/XplorePlane/Libs/Hardware/zh-TW/XP.Common.resources.dll diff --git a/README.md b/README.md index 2cf2181..0fed7a8 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ XplorePlane/ - [x] 日志库的引用(通过 XplorePlane.Common.dll) - [x] 按推荐的 DLL 目录结构进行修改 - [x] 通过库依赖的方式调用日志功能 -- [ ] 界面的布局 +- [x] 界面的布局 - [ ] 打通与硬件层的调用流程 - [ ] 打通与图像层的调用流程 diff --git a/XplorePlane.sln b/XplorePlane.sln index e5c7cfb..2f7e9f6 100644 --- a/XplorePlane.sln +++ b/XplorePlane.sln @@ -1,20 +1,46 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 d17.14 +VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XplorePlane", "XplorePlane\XplorePlane.csproj", "{07978DB9-4B88-4F42-9054-73992742BD6A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XplorePlane.Tests", "XplorePlane.Tests\XplorePlane.Tests.csproj", "{6234B622-8DF2-4A8D-AF93-B17774019555}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|x64.ActiveCfg = Debug|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|x64.Build.0 = Debug|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Debug|x86.Build.0 = Debug|Any CPU {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|Any CPU.ActiveCfg = Release|Any CPU {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|Any CPU.Build.0 = Release|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x64.ActiveCfg = Release|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x64.Build.0 = Release|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x86.ActiveCfg = Release|Any CPU + {07978DB9-4B88-4F42-9054-73992742BD6A}.Release|x86.Build.0 = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x64.ActiveCfg = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x64.Build.0 = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x86.ActiveCfg = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Debug|x86.Build.0 = Debug|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|Any CPU.Build.0 = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x64.ActiveCfg = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x64.Build.0 = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x86.ActiveCfg = Release|Any CPU + {6234B622-8DF2-4A8D-AF93-B17774019555}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index a15cf4d..eef20cb 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -174,6 +174,14 @@ namespace XplorePlane containerRegistry.Register(); containerRegistry.RegisterForNavigation(); + // 注册流水线服务(单例,共享 IImageProcessingService) + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + + // 注册流水线 ViewModel(每次解析创建新实例) + containerRegistry.Register(); + containerRegistry.Register(); + Log.Information("依赖注入容器配置完成"); } diff --git a/XplorePlane/Models/PipelineModels.cs b/XplorePlane/Models/PipelineModels.cs new file mode 100644 index 0000000..79dd981 --- /dev/null +++ b/XplorePlane/Models/PipelineModels.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + public class PipelineModel + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public List Nodes { get; set; } = new(); + } + + public class PipelineNodeModel + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string OperatorKey { get; set; } = string.Empty; + public int Order { get; set; } + public bool IsEnabled { get; set; } = true; + public Dictionary Parameters { get; set; } = new(); + } + + public class PipelineReorderArgs + { + public int OldIndex { get; set; } + public int NewIndex { get; set; } + } +} diff --git a/XplorePlane/Services/IPipelineExecutionService.cs b/XplorePlane/Services/IPipelineExecutionService.cs new file mode 100644 index 0000000..305d2f2 --- /dev/null +++ b/XplorePlane/Services/IPipelineExecutionService.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using XplorePlane.ViewModels; + +namespace XplorePlane.Services +{ + public record PipelineProgress(int CurrentStep, int TotalSteps, string CurrentOperator); + + public interface IPipelineExecutionService + { + Task ExecutePipelineAsync( + IEnumerable nodes, + BitmapSource source, + IProgress progress = null, + CancellationToken cancellationToken = default); + } +} diff --git a/XplorePlane/Services/IPipelinePersistenceService.cs b/XplorePlane/Services/IPipelinePersistenceService.cs new file mode 100644 index 0000000..a8861e8 --- /dev/null +++ b/XplorePlane/Services/IPipelinePersistenceService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services +{ + public interface IPipelinePersistenceService + { + Task SaveAsync(PipelineModel pipeline, string filePath); + Task LoadAsync(string filePath); + Task> LoadAllAsync(string directory); + } +} diff --git a/XplorePlane/Services/PipelineExecutionException.cs b/XplorePlane/Services/PipelineExecutionException.cs new file mode 100644 index 0000000..22712a6 --- /dev/null +++ b/XplorePlane/Services/PipelineExecutionException.cs @@ -0,0 +1,24 @@ +using System; + +namespace XplorePlane.Services +{ + public class PipelineExecutionException : Exception + { + public int FailedNodeOrder { get; } + public string FailedOperatorKey { get; } + + public PipelineExecutionException(string message, int failedNodeOrder, string failedOperatorKey) + : base(message) + { + FailedNodeOrder = failedNodeOrder; + FailedOperatorKey = failedOperatorKey; + } + + public PipelineExecutionException(string message, int failedNodeOrder, string failedOperatorKey, Exception innerException) + : base(message, innerException) + { + FailedNodeOrder = failedNodeOrder; + FailedOperatorKey = failedOperatorKey; + } + } +} diff --git a/XplorePlane/Services/PipelineExecutionService.cs b/XplorePlane/Services/PipelineExecutionService.cs new file mode 100644 index 0000000..f7ce647 --- /dev/null +++ b/XplorePlane/Services/PipelineExecutionService.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using XplorePlane.ViewModels; + +namespace XplorePlane.Services +{ + public class PipelineExecutionService : IPipelineExecutionService + { + private const int PreviewMaxHeight = 1080; + private const int UhdThreshold = 3840; + + private readonly IImageProcessingService _imageProcessingService; + + public PipelineExecutionService(IImageProcessingService imageProcessingService) + { + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + } + + public async Task ExecutePipelineAsync( + IEnumerable nodes, + BitmapSource source, + IProgress progress = null, + CancellationToken cancellationToken = default) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + var enabledNodes = (nodes ?? Enumerable.Empty()) + .Where(n => n.IsEnabled) + .OrderBy(n => n.Order) + .ToList(); + + if (enabledNodes.Count == 0) + return source; + + // 大图像预览缩放 + var current = ScaleForPreview(source); + + int total = enabledNodes.Count; + for (int step = 0; step < total; step++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var node = enabledNodes[step]; + var parameters = node.Parameters + .Where(p => p.IsValueValid) + .ToDictionary(p => p.Name, p => p.Value); + + try + { + current = await _imageProcessingService.ProcessImageAsync( + current, node.OperatorKey, parameters, null, cancellationToken); + + if (current == null) + throw new PipelineExecutionException( + $"算子 '{node.OperatorKey}' 返回了空图像", + node.Order, node.OperatorKey); + } + catch (OperationCanceledException) + { + throw; + } + catch (PipelineExecutionException) + { + throw; + } + catch (Exception ex) + { + throw new PipelineExecutionException( + $"算子 '{node.DisplayName}' 执行失败:{ex.Message}", + node.Order, node.OperatorKey, ex); + } + + progress?.Report(new PipelineProgress(step + 1, total, node.DisplayName)); + } + + if (!current.IsFrozen) + current.Freeze(); + + return current; + } + + private static BitmapSource ScaleForPreview(BitmapSource source) + { + if (source.PixelWidth <= UhdThreshold && source.PixelHeight <= UhdThreshold) + return source; + + double scale = (double)PreviewMaxHeight / source.PixelHeight; + if (source.PixelWidth * scale > UhdThreshold) + scale = (double)UhdThreshold / source.PixelWidth; + + var scaled = new TransformedBitmap(source, new ScaleTransform(scale, scale)); + scaled.Freeze(); + return scaled; + } + } +} diff --git a/XplorePlane/Services/PipelinePersistenceService.cs b/XplorePlane/Services/PipelinePersistenceService.cs new file mode 100644 index 0000000..ec6617f --- /dev/null +++ b/XplorePlane/Services/PipelinePersistenceService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services +{ + public class PipelinePersistenceService : IPipelinePersistenceService + { + private readonly IImageProcessingService _imageProcessingService; + private readonly string _baseDirectory; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + public PipelinePersistenceService(IImageProcessingService imageProcessingService, string baseDirectory = null) + { + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + _baseDirectory = baseDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", "Pipelines"); + } + + public async Task SaveAsync(PipelineModel pipeline, string filePath) + { + if (pipeline == null) throw new ArgumentNullException(nameof(pipeline)); + ValidatePath(filePath); + + pipeline.UpdatedAt = DateTime.UtcNow; + var json = JsonSerializer.Serialize(pipeline, JsonOptions); + await File.WriteAllTextAsync(filePath, json); + } + + public async Task LoadAsync(string filePath) + { + ValidatePath(filePath); + + if (!File.Exists(filePath)) + throw new FileNotFoundException("流水线文件不存在", filePath); + + var json = await File.ReadAllTextAsync(filePath); + var model = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("流水线文件格式无效"); + + // 白名单验证:过滤未注册的算子键 + var available = _imageProcessingService.GetAvailableProcessors().ToHashSet(StringComparer.OrdinalIgnoreCase); + model.Nodes = model.Nodes + .Where(n => available.Contains(n.OperatorKey)) + .OrderBy(n => n.Order) + .ToList(); + + // 重新编号确保 Order 连续 + for (int i = 0; i < model.Nodes.Count; i++) + model.Nodes[i].Order = i; + + return model; + } + + public async Task> LoadAllAsync(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + directory = _baseDirectory; + + ValidateDirectory(directory); + + if (!Directory.Exists(directory)) + return Array.Empty(); + + var files = Directory.GetFiles(directory, "*.pipeline.json"); + var results = new List(); + + foreach (var file in files) + { + try + { + var model = await LoadAsync(file); + results.Add(model); + } + catch + { + // 跳过无效文件 + } + } + + return results; + } + + private void ValidatePath(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("文件路径不能为空", nameof(filePath)); + + if (filePath.Contains("..")) + throw new UnauthorizedAccessException($"不允许路径遍历:{filePath}"); + } + + private void ValidateDirectory(string directory) + { + if (directory.Contains("..")) + throw new UnauthorizedAccessException($"不允许路径遍历:{directory}"); + } + } +} diff --git a/XplorePlane/ViewModels/MainViewModel.cs b/XplorePlane/ViewModels/MainViewModel.cs index bd35a80..a7110e6 100644 --- a/XplorePlane/ViewModels/MainViewModel.cs +++ b/XplorePlane/ViewModels/MainViewModel.cs @@ -25,6 +25,7 @@ namespace XplorePlane.ViewModels public DelegateCommand ClearCommand { get; set; } public DelegateCommand EditPropertiesCommand { get; set; } public DelegateCommand OpenImageProcessingCommand { get; set; } + public DelegateCommand OpenPipelineEditorCommand { get; set; } public MainViewModel(ILogger logger) { @@ -45,6 +46,13 @@ namespace XplorePlane.ViewModels _logger.Information("图像处理窗口已打开"); }); + OpenPipelineEditorCommand = new DelegateCommand(() => + { + var window = new Views.PipelineEditorWindow(); + window.Show(); + _logger.Information("流水线编辑器窗口已打开"); + }); + _logger.Information("MainViewModel 已初始化"); } diff --git a/XplorePlane/ViewModels/OperatorToolboxViewModel.cs b/XplorePlane/ViewModels/OperatorToolboxViewModel.cs new file mode 100644 index 0000000..b8ba48f --- /dev/null +++ b/XplorePlane/ViewModels/OperatorToolboxViewModel.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Prism.Mvvm; +using XplorePlane.Services; + +namespace XplorePlane.ViewModels +{ + public class OperatorDescriptor + { + public string Key { get; } + public string DisplayName { get; } + public string IconPath { get; } + public string Category { get; } + + public OperatorDescriptor(string key, string displayName, string iconPath = "", string category = "通用") + { + Key = key; + DisplayName = displayName; + IconPath = iconPath; + Category = category; + } + } + + public class OperatorToolboxViewModel : BindableBase + { + private readonly IImageProcessingService _imageProcessingService; + private string _searchText = string.Empty; + + public OperatorToolboxViewModel(IImageProcessingService imageProcessingService) + { + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + AvailableOperators = new ObservableCollection(); + FilteredOperators = new ObservableCollection(); + LoadOperators(); + } + + public ObservableCollection AvailableOperators { get; } + public ObservableCollection FilteredOperators { get; } + + public string SearchText + { + get => _searchText; + set + { + if (SetProperty(ref _searchText, value)) + ApplyFilter(); + } + } + + private void LoadOperators() + { + AvailableOperators.Clear(); + foreach (var key in _imageProcessingService.GetAvailableProcessors()) + { + var displayName = _imageProcessingService.GetProcessorDisplayName(key); + AvailableOperators.Add(new OperatorDescriptor(key, displayName ?? key)); + } + ApplyFilter(); + } + + private void ApplyFilter() + { + FilteredOperators.Clear(); + var filtered = string.IsNullOrWhiteSpace(SearchText) + ? AvailableOperators + : AvailableOperators.Where(o => + o.DisplayName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + foreach (var op in filtered) + FilteredOperators.Add(op); + } + } +} diff --git a/XplorePlane/ViewModels/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/PipelineEditorViewModel.cs new file mode 100644 index 0000000..ecc4b96 --- /dev/null +++ b/XplorePlane/ViewModels/PipelineEditorViewModel.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; +using Microsoft.Win32; +using Prism.Commands; +using Prism.Mvvm; +using XplorePlane.Models; +using XplorePlane.Services; + +namespace XplorePlane.ViewModels +{ + public class PipelineEditorViewModel : BindableBase + { + private const int MaxPipelineLength = 20; + private const int DebounceDelayMs = 300; + + private readonly IImageProcessingService _imageProcessingService; + private readonly IPipelineExecutionService _executionService; + private readonly IPipelinePersistenceService _persistenceService; + + private PipelineNodeViewModel _selectedNode; + private BitmapSource _sourceImage; + private BitmapSource _previewImage; + private string _pipelineName = "新建流水线"; + private string _selectedDevice = string.Empty; + private bool _isExecuting; + private string _statusMessage = string.Empty; + private string _currentFilePath; + + private CancellationTokenSource _executionCts; + private CancellationTokenSource _debounceCts; + + public PipelineEditorViewModel( + IImageProcessingService imageProcessingService, + IPipelineExecutionService executionService, + IPipelinePersistenceService persistenceService) + { + _imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService)); + _executionService = executionService ?? throw new ArgumentNullException(nameof(executionService)); + _persistenceService = persistenceService ?? throw new ArgumentNullException(nameof(persistenceService)); + + PipelineNodes = new ObservableCollection(); + AvailableDevices = new ObservableCollection(); + + AddOperatorCommand = new DelegateCommand(AddOperator, CanAddOperator); + RemoveOperatorCommand = new DelegateCommand(RemoveOperator); + ReorderOperatorCommand = new DelegateCommand(ReorderOperator); + ExecutePipelineCommand = new DelegateCommand(async () => await ExecutePipelineAsync(), () => !IsExecuting && SourceImage != null); + CancelExecutionCommand = new DelegateCommand(CancelExecution, () => IsExecuting); + NewPipelineCommand = new DelegateCommand(NewPipeline); + SavePipelineCommand = new DelegateCommand(async () => await SavePipelineAsync()); + SaveAsPipelineCommand = new DelegateCommand(async () => await SaveAsPipelineAsync()); + DeletePipelineCommand = new DelegateCommand(async () => await DeletePipelineAsync()); + } + + // ── State Properties ────────────────────────────────────────── + + public ObservableCollection PipelineNodes { get; } + public ObservableCollection AvailableDevices { get; } + + public PipelineNodeViewModel SelectedNode + { + get => _selectedNode; + set + { + if (SetProperty(ref _selectedNode, value) && value != null) + LoadNodeParameters(value); + } + } + + public BitmapSource SourceImage + { + get => _sourceImage; + set + { + if (SetProperty(ref _sourceImage, value)) + { + ExecutePipelineCommand.RaiseCanExecuteChanged(); + TriggerDebouncedExecution(); + } + } + } + + public BitmapSource PreviewImage + { + get => _previewImage; + set => SetProperty(ref _previewImage, value); + } + + public string PipelineName + { + get => _pipelineName; + set => SetProperty(ref _pipelineName, value); + } + + public string SelectedDevice + { + get => _selectedDevice; + set => SetProperty(ref _selectedDevice, value); + } + + public bool IsExecuting + { + get => _isExecuting; + private set + { + if (SetProperty(ref _isExecuting, value)) + { + ExecutePipelineCommand.RaiseCanExecuteChanged(); + CancelExecutionCommand.RaiseCanExecuteChanged(); + } + } + } + + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + + // ── Commands ────────────────────────────────────────────────── + + public DelegateCommand AddOperatorCommand { get; } + public DelegateCommand RemoveOperatorCommand { get; } + public DelegateCommand ReorderOperatorCommand { get; } + public DelegateCommand ExecutePipelineCommand { get; } + public DelegateCommand CancelExecutionCommand { get; } + public DelegateCommand NewPipelineCommand { get; } + public DelegateCommand SavePipelineCommand { get; } + public DelegateCommand SaveAsPipelineCommand { get; } + public DelegateCommand DeletePipelineCommand { get; } + + // ── Command Implementations ─────────────────────────────────── + + private bool CanAddOperator(string operatorKey) => + !string.IsNullOrWhiteSpace(operatorKey) && PipelineNodes.Count < MaxPipelineLength; + + private void AddOperator(string operatorKey) + { + if (string.IsNullOrWhiteSpace(operatorKey)) + { + StatusMessage = "算子键不能为空"; + return; + } + + var available = _imageProcessingService.GetAvailableProcessors(); + if (!available.Contains(operatorKey)) + { + StatusMessage = $"算子 '{operatorKey}' 未注册"; + return; + } + + if (PipelineNodes.Count >= MaxPipelineLength) + { + StatusMessage = $"流水线节点数已达上限({MaxPipelineLength})"; + return; + } + + var displayName = _imageProcessingService.GetProcessorDisplayName(operatorKey) ?? operatorKey; + var node = new PipelineNodeViewModel(operatorKey, displayName) + { + Order = PipelineNodes.Count + }; + LoadNodeParameters(node); + PipelineNodes.Add(node); + StatusMessage = $"已添加算子:{displayName}"; + TriggerDebouncedExecution(); + } + + private void RemoveOperator(PipelineNodeViewModel node) + { + if (node == null || !PipelineNodes.Contains(node)) return; + + PipelineNodes.Remove(node); + RenumberNodes(); + + if (SelectedNode == node) + SelectedNode = null; + + StatusMessage = $"已移除算子:{node.DisplayName}"; + TriggerDebouncedExecution(); + } + + private void ReorderOperator(PipelineReorderArgs args) + { + if (args == null) return; + int oldIndex = args.OldIndex; + int newIndex = args.NewIndex; + + if (oldIndex < 0 || oldIndex >= PipelineNodes.Count) return; + if (newIndex < 0 || newIndex >= PipelineNodes.Count) return; + if (oldIndex == newIndex) return; + + var node = PipelineNodes[oldIndex]; + PipelineNodes.RemoveAt(oldIndex); + PipelineNodes.Insert(newIndex, node); + RenumberNodes(); + TriggerDebouncedExecution(); + } + + private void RenumberNodes() + { + for (int i = 0; i < PipelineNodes.Count; i++) + PipelineNodes[i].Order = i; + } + + private void LoadNodeParameters(PipelineNodeViewModel node) + { + var paramDefs = _imageProcessingService.GetProcessorParameters(node.OperatorKey); + if (paramDefs == null) return; + + // 保留已有参数快照 + var snapshot = node.Parameters.ToDictionary(p => p.Name, p => p.Value); + node.Parameters.Clear(); + + foreach (var paramDef in paramDefs) + { + var vm = new ProcessorParameterVM(paramDef); + if (snapshot.TryGetValue(paramDef.Name, out var savedValue)) + vm.Value = savedValue; + vm.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ProcessorParameterVM.Value)) + TriggerDebouncedExecution(); + }; + node.Parameters.Add(vm); + } + } + + private async Task ExecutePipelineAsync() + { + if (SourceImage == null || IsExecuting) return; + + _executionCts?.Cancel(); + _executionCts = new CancellationTokenSource(); + var token = _executionCts.Token; + + IsExecuting = true; + StatusMessage = "正在执行流水线..."; + + try + { + var progress = new Progress(p => + StatusMessage = $"执行中:{p.CurrentOperator} ({p.CurrentStep}/{p.TotalSteps})"); + + var result = await _executionService.ExecutePipelineAsync( + PipelineNodes, SourceImage, progress, token); + + PreviewImage = result; + StatusMessage = "流水线执行完成"; + } + catch (OperationCanceledException) + { + StatusMessage = "流水线执行已取消"; + } + catch (PipelineExecutionException ex) + { + StatusMessage = $"节点 '{ex.FailedOperatorKey}' 执行失败:{ex.Message}"; + } + catch (Exception ex) + { + StatusMessage = $"执行错误:{ex.Message}"; + } + finally + { + IsExecuting = false; + } + } + + private void CancelExecution() + { + _executionCts?.Cancel(); + } + + private void TriggerDebouncedExecution() + { + if (SourceImage == null) return; + + _debounceCts?.Cancel(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + Task.Delay(DebounceDelayMs, token).ContinueWith(t => + { + if (!t.IsCanceled) + _ = ExecutePipelineAsync(); + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + + private void NewPipeline() + { + PipelineNodes.Clear(); + SelectedNode = null; + PipelineName = "新建流水线"; + PreviewImage = null; + _currentFilePath = null; + StatusMessage = "已新建流水线"; + } + + private async Task SavePipelineAsync() + { + if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100) + { + StatusMessage = "流水线名称不能为空且长度不超过 100 个字符"; + return; + } + + if (string.IsNullOrEmpty(_currentFilePath)) + { + await SaveAsPipelineAsync(); + return; + } + + await SaveToFileAsync(_currentFilePath); + } + + private async Task SaveAsPipelineAsync() + { + if (string.IsNullOrWhiteSpace(PipelineName) || PipelineName.Length > 100) + { + StatusMessage = "流水线名称不能为空且长度不超过 100 个字符"; + return; + } + + var dialog = new SaveFileDialog + { + Filter = "流水线文件 (*.pipeline.json)|*.pipeline.json", + FileName = PipelineName, + InitialDirectory = GetPipelineDirectory() + }; + + if (dialog.ShowDialog() == true) + { + _currentFilePath = dialog.FileName; + await SaveToFileAsync(_currentFilePath); + } + } + + private async Task SaveToFileAsync(string filePath) + { + try + { + var model = BuildPipelineModel(); + await _persistenceService.SaveAsync(model, filePath); + StatusMessage = $"流水线已保存:{Path.GetFileName(filePath)}"; + } + catch (IOException ex) + { + StatusMessage = $"保存失败:{ex.Message}"; + } + } + + private async Task DeletePipelineAsync() + { + if (string.IsNullOrEmpty(_currentFilePath)) return; + + try + { + if (File.Exists(_currentFilePath)) + File.Delete(_currentFilePath); + + NewPipeline(); + StatusMessage = "流水线已删除"; + } + catch (IOException ex) + { + StatusMessage = $"删除失败:{ex.Message}"; + } + await Task.CompletedTask; + } + + private PipelineModel BuildPipelineModel() + { + return new PipelineModel + { + Name = PipelineName, + DeviceId = SelectedDevice, + UpdatedAt = DateTime.UtcNow, + Nodes = PipelineNodes.Select(n => new PipelineNodeModel + { + Id = n.Id, + OperatorKey = n.OperatorKey, + Order = n.Order, + IsEnabled = n.IsEnabled, + Parameters = n.Parameters.ToDictionary(p => p.Name, p => p.Value) + }).ToList() + }; + } + + private static string GetPipelineDirectory() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", "Pipelines"); + Directory.CreateDirectory(dir); + return dir; + } + } +} diff --git a/XplorePlane/ViewModels/PipelineNodeViewModel.cs b/XplorePlane/ViewModels/PipelineNodeViewModel.cs new file mode 100644 index 0000000..3a5ef5d --- /dev/null +++ b/XplorePlane/ViewModels/PipelineNodeViewModel.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.ObjectModel; +using Prism.Mvvm; + +namespace XplorePlane.ViewModels +{ + public class PipelineNodeViewModel : BindableBase + { + private string _displayName; + private string _iconPath; + private int _order; + private bool _isSelected; + private bool _isEnabled = true; + + public PipelineNodeViewModel(string operatorKey, string displayName, string iconPath = null) + { + Id = Guid.NewGuid(); + OperatorKey = operatorKey; + _displayName = displayName; + _iconPath = iconPath ?? string.Empty; + Parameters = new ObservableCollection(); + } + + public Guid Id { get; } + public string OperatorKey { get; } + + public string DisplayName + { + get => _displayName; + set => SetProperty(ref _displayName, value); + } + + public string IconPath + { + get => _iconPath; + set => SetProperty(ref _iconPath, value); + } + + public int Order + { + get => _order; + set => SetProperty(ref _order, value); + } + + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } + + public bool IsEnabled + { + get => _isEnabled; + set => SetProperty(ref _isEnabled, value); + } + + public ObservableCollection Parameters { get; } + } +} diff --git a/XplorePlane/ViewModels/ProcessorParameterVM.cs b/XplorePlane/ViewModels/ProcessorParameterVM.cs index d8539f2..38de540 100644 --- a/XplorePlane/ViewModels/ProcessorParameterVM.cs +++ b/XplorePlane/ViewModels/ProcessorParameterVM.cs @@ -1,3 +1,4 @@ +using System; using ImageProcessing.Core; using Prism.Mvvm; @@ -6,6 +7,7 @@ namespace XplorePlane.ViewModels public class ProcessorParameterVM : BindableBase { private object _value; + private bool _isValueValid = true; public ProcessorParameterVM(ProcessorParameter parameter) { @@ -21,6 +23,7 @@ namespace XplorePlane.ViewModels "boolean" or "bool" => "bool", _ => "enum" }; + ValidateValue(_value); } public string Name { get; } @@ -29,10 +32,40 @@ namespace XplorePlane.ViewModels public object MaxValue { get; } public string ParameterType { get; } + public bool IsValueValid + { + get => _isValueValid; + private set => SetProperty(ref _isValueValid, value); + } + public object Value { get => _value; - set => SetProperty(ref _value, value); + set + { + if (SetProperty(ref _value, value)) + ValidateValue(value); + } + } + + private void ValidateValue(object value) + { + if (value == null || MinValue == null || MaxValue == null) + { + IsValueValid = true; + return; + } + try + { + double dVal = Convert.ToDouble(value); + double dMin = Convert.ToDouble(MinValue); + double dMax = Convert.ToDouble(MaxValue); + IsValueValid = dVal >= dMin && dVal <= dMax; + } + catch + { + IsValueValid = true; // 非数值类型不做范围校验 + } } } } diff --git a/XplorePlane/Views/MainWindow.xaml b/XplorePlane/Views/MainWindow.xaml index 07a733b..a23d27e 100644 --- a/XplorePlane/Views/MainWindow.xaml +++ b/XplorePlane/Views/MainWindow.xaml @@ -1714,6 +1714,10 @@ Command="{Binding OpenImageProcessingCommand}" Size="Large" Text="图像处理" /> + diff --git a/XplorePlane/Views/OperatorToolboxView.xaml b/XplorePlane/Views/OperatorToolboxView.xaml new file mode 100644 index 0000000..6e2e742 --- /dev/null +++ b/XplorePlane/Views/OperatorToolboxView.xaml @@ -0,0 +1,84 @@ + + + + + + Microsoft YaHei UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XplorePlane/Views/OperatorToolboxView.xaml.cs b/XplorePlane/Views/OperatorToolboxView.xaml.cs new file mode 100644 index 0000000..53aff74 --- /dev/null +++ b/XplorePlane/Views/OperatorToolboxView.xaml.cs @@ -0,0 +1,19 @@ +using System.Windows.Controls; +using Prism.Ioc; +using XplorePlane.ViewModels; + +namespace XplorePlane.Views +{ + public partial class OperatorToolboxView : UserControl + { + public OperatorToolboxView() + { + InitializeComponent(); + Loaded += (_, _) => + { + if (DataContext == null) + DataContext = ContainerLocator.Container.Resolve(); + }; + } + } +} diff --git a/XplorePlane/Views/PipelineEditorView.xaml b/XplorePlane/Views/PipelineEditorView.xaml new file mode 100644 index 0000000..2749d26 --- /dev/null +++ b/XplorePlane/Views/PipelineEditorView.xaml @@ -0,0 +1,221 @@ + + + + + + + + Microsoft YaHei UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +