#0020 浮动图像处理工具箱调研

This commit is contained in:
zhengxuan.zhang
2026-03-14 23:21:19 +08:00
parent e89767d976
commit 7c7d0b6a20
29 changed files with 1311 additions and 4 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ XplorePlane/
- [x] 日志库的引用(通过 XplorePlane.Common.dll
- [x] 按推荐的 DLL 目录结构进行修改
- [x] 通过库依赖的方式调用日志功能
- [ ] 界面的布局
- [x] 界面的布局
- [ ] 打通与硬件层的调用流程
- [ ] 打通与图像层的调用流程
+27 -1
View File
@@ -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
+8
View File
@@ -174,6 +174,14 @@ namespace XplorePlane
containerRegistry.Register<ImageProcessingViewModel>();
containerRegistry.RegisterForNavigation<ImageProcessingPanelView>();
// 注册流水线服务(单例,共享 IImageProcessingService
containerRegistry.RegisterSingleton<IPipelineExecutionService, PipelineExecutionService>();
containerRegistry.RegisterSingleton<IPipelinePersistenceService, PipelinePersistenceService>();
// 注册流水线 ViewModel(每次解析创建新实例)
containerRegistry.Register<PipelineEditorViewModel>();
containerRegistry.Register<OperatorToolboxViewModel>();
Log.Information("依赖注入容器配置完成");
}
+30
View File
@@ -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<PipelineNodeModel> 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<string, object> Parameters { get; set; } = new();
}
public class PipelineReorderArgs
{
public int OldIndex { get; set; }
public int NewIndex { get; set; }
}
}
@@ -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<BitmapSource> ExecutePipelineAsync(
IEnumerable<PipelineNodeViewModel> nodes,
BitmapSource source,
IProgress<PipelineProgress> progress = null,
CancellationToken cancellationToken = default);
}
}
@@ -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<PipelineModel> LoadAsync(string filePath);
Task<IReadOnlyList<PipelineModel>> LoadAllAsync(string directory);
}
}
@@ -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;
}
}
}
@@ -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<BitmapSource> ExecutePipelineAsync(
IEnumerable<PipelineNodeViewModel> nodes,
BitmapSource source,
IProgress<PipelineProgress> progress = null,
CancellationToken cancellationToken = default)
{
if (source == null) throw new ArgumentNullException(nameof(source));
var enabledNodes = (nodes ?? Enumerable.Empty<PipelineNodeViewModel>())
.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;
}
}
}
@@ -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<PipelineModel> LoadAsync(string filePath)
{
ValidatePath(filePath);
if (!File.Exists(filePath))
throw new FileNotFoundException("流水线文件不存在", filePath);
var json = await File.ReadAllTextAsync(filePath);
var model = JsonSerializer.Deserialize<PipelineModel>(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<IReadOnlyList<PipelineModel>> LoadAllAsync(string directory)
{
if (string.IsNullOrWhiteSpace(directory))
directory = _baseDirectory;
ValidateDirectory(directory);
if (!Directory.Exists(directory))
return Array.Empty<PipelineModel>();
var files = Directory.GetFiles(directory, "*.pipeline.json");
var results = new List<PipelineModel>();
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}");
}
}
}
+8
View File
@@ -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 已初始化");
}
@@ -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<OperatorDescriptor>();
FilteredOperators = new ObservableCollection<OperatorDescriptor>();
LoadOperators();
}
public ObservableCollection<OperatorDescriptor> AvailableOperators { get; }
public ObservableCollection<OperatorDescriptor> 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);
}
}
}
@@ -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<PipelineNodeViewModel>();
AvailableDevices = new ObservableCollection<string>();
AddOperatorCommand = new DelegateCommand<string>(AddOperator, CanAddOperator);
RemoveOperatorCommand = new DelegateCommand<PipelineNodeViewModel>(RemoveOperator);
ReorderOperatorCommand = new DelegateCommand<PipelineReorderArgs>(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<PipelineNodeViewModel> PipelineNodes { get; }
public ObservableCollection<string> 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<string> AddOperatorCommand { get; }
public DelegateCommand<PipelineNodeViewModel> RemoveOperatorCommand { get; }
public DelegateCommand<PipelineReorderArgs> 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<PipelineProgress>(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;
}
}
}
@@ -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<ProcessorParameterVM>();
}
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<ProcessorParameterVM> Parameters { get; }
}
}
+34 -1
View File
@@ -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; // 非数值类型不做范围校验
}
}
}
}
+4
View File
@@ -1714,6 +1714,10 @@
Command="{Binding OpenImageProcessingCommand}"
Size="Large"
Text="图像处理" />
<telerik:RadRibbonButton
Command="{Binding OpenPipelineEditorCommand}"
Size="Large"
Text="流水线编辑器" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
@@ -0,0 +1,84 @@
<UserControl x:Class="XplorePlane.Views.OperatorToolboxView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="200">
<UserControl.Resources>
<SolidColorBrush x:Key="PanelBg" Color="White"/>
<SolidColorBrush x:Key="PanelBorder" Color="#cdcbcb"/>
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
</UserControl.Resources>
<Border Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="1" CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 标题 -->
<Border Grid.Row="0" Background="#F0F0F0" BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1" Padding="8,6">
<TextBlock Text="算子工具箱" FontFamily="{StaticResource CsdFont}"
FontWeight="Bold" FontSize="12" Foreground="#1c1c1b"/>
</Border>
<!-- 搜索框 -->
<Border Grid.Row="1" Padding="6,4" BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1">
<TextBox Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
FontFamily="{StaticResource CsdFont}" FontSize="11"
Padding="4,2" BorderBrush="#cdcbcb" BorderThickness="1">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Text" Value="">
<Setter Property="Background">
<Setter.Value>
<VisualBrush AlignmentX="Left" AlignmentY="Center" Stretch="None">
<VisualBrush.Visual>
<TextBlock Text="搜索算子..." Foreground="#aaa"
FontFamily="Microsoft YaHei UI" FontSize="11"
Margin="4,0,0,0"/>
</VisualBrush.Visual>
</VisualBrush>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
</Border>
<!-- 算子列表(拖拽源) -->
<telerik:RadListBox Grid.Row="2"
ItemsSource="{Binding FilteredOperators}"
BorderThickness="0"
Background="Transparent"
telerik:RadDragAndDropManager.AllowDrag="True">
<telerik:RadListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="4,3">
<Border Width="24" Height="24" Background="#E8F0FE"
CornerRadius="3" Margin="0,0,6,0">
<TextBlock Text="⚙" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12"/>
</Border>
<TextBlock Text="{Binding DisplayName}"
FontFamily="Microsoft YaHei UI" FontSize="11"
VerticalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</telerik:RadListBox.ItemTemplate>
</telerik:RadListBox>
</Grid>
</Border>
</UserControl>
@@ -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<OperatorToolboxViewModel>();
};
}
}
}
+221
View File
@@ -0,0 +1,221 @@
<UserControl x:Class="XplorePlane.Views.PipelineEditorView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
mc:Ignorable="d"
d:DesignHeight="700" d:DesignWidth="400">
<UserControl.Resources>
<SolidColorBrush x:Key="PanelBg" Color="White"/>
<SolidColorBrush x:Key="PanelBorder" Color="#cdcbcb"/>
<SolidColorBrush x:Key="SeparatorBrush" Color="#E0E0E0"/>
<SolidColorBrush x:Key="AccentBlue" Color="#E3F0FF"/>
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<!-- 节点项样式 -->
<Style x:Key="PipelineNodeItemStyle" TargetType="telerik:RadListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
<!-- 工具栏按钮样式 -->
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Width" Value="28"/>
<Setter Property="Height" Value="28"/>
<Setter Property="Margin" Value="2,0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="#cdcbcb"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Cursor" Value="Hand"/>
</Style>
</UserControl.Resources>
<Border Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="1" CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="180"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 标题栏:流水线名称 + 设备选择 -->
<Border Grid.Row="0" Background="#F0F0F0"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1" Padding="8,5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="图像处理 ▾"
FontFamily="{StaticResource CsdFont}"
FontWeight="Bold" FontSize="12"
Foreground="#1c1c1b" VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBox Grid.Column="1"
Text="{Binding PipelineName, UpdateSourceTrigger=PropertyChanged}"
FontFamily="{StaticResource CsdFont}" FontSize="11"
BorderThickness="0" Background="Transparent"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="设备:"
FontFamily="{StaticResource CsdFont}" FontSize="11"
VerticalAlignment="Center" Margin="0,0,4,0"/>
<telerik:RadComboBox Grid.Column="3"
ItemsSource="{Binding AvailableDevices}"
SelectedItem="{Binding SelectedDevice}"
FontFamily="{StaticResource CsdFont}" FontSize="11"
Height="22"/>
</Grid>
</Border>
<!-- 流水线节点列表(拖拽目标) -->
<telerik:RadListBox Grid.Row="1"
x:Name="PipelineListBox"
ItemsSource="{Binding PipelineNodes}"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}"
BorderThickness="0"
Background="Transparent"
telerik:RadDragAndDropManager.AllowDrop="True"
ItemContainerStyle="{StaticResource PipelineNodeItemStyle}">
<telerik:RadListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 竖线连接 -->
<Canvas Grid.Column="0" Width="20">
<Line X1="10" Y1="0" X2="10" Y2="40"
Stroke="#5B9BD5" StrokeThickness="2"/>
<Rectangle Canvas.Left="3" Canvas.Top="12"
Width="14" Height="14"
Fill="White" Stroke="#5B9BD5" StrokeThickness="1.5"
RadiusX="2" RadiusY="2"/>
</Canvas>
<!-- 节点行 -->
<Border Grid.Column="1" Padding="6,8"
BorderBrush="Transparent" BorderThickness="0">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter Property="Background" Value="{StaticResource AccentBlue}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<Border Width="28" Height="28" Background="#E8F0FE"
CornerRadius="3" Margin="0,0,8,0">
<TextBlock Text="⚙" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="13"/>
</Border>
<TextBlock Text="{Binding DisplayName}"
FontFamily="Microsoft YaHei UI" FontSize="12"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</DataTemplate>
</telerik:RadListBox.ItemTemplate>
</telerik:RadListBox>
<!-- 分隔线 -->
<Rectangle Grid.Row="2" Height="1" Fill="{StaticResource SeparatorBrush}"/>
<!-- 参数面板 -->
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,6">
<TextBlock Text="参数配置" FontFamily="{StaticResource CsdFont}"
FontWeight="Bold" FontSize="11" Foreground="#555"
Margin="0,0,0,4"/>
<ItemsControl ItemsSource="{Binding SelectedNode.Parameters}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,3">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding DisplayName}"
FontFamily="Microsoft YaHei UI" FontSize="11"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<TextBox Grid.Column="1"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Microsoft YaHei UI" FontSize="11"
Padding="4,2" BorderBrush="#cdcbcb" BorderThickness="1">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#cdcbcb"/>
<Setter Property="Background" Value="White"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsValueValid}" Value="False">
<Setter Property="BorderBrush" Value="Red"/>
<Setter Property="Background" Value="#FFF0F0"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- 工具栏 -->
<Border Grid.Row="4" Background="#F5F5F5"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,1,0,1" Padding="6,4">
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="新" Style="{StaticResource ToolbarBtn}"
Command="{Binding NewPipelineCommand}"
ToolTip="新建流水线"/>
<Button Content="存" Style="{StaticResource ToolbarBtn}"
Command="{Binding SavePipelineCommand}"
ToolTip="保存流水线"/>
<Button Content="另" Style="{StaticResource ToolbarBtn}"
Command="{Binding SaveAsPipelineCommand}"
ToolTip="另存为"/>
<Button Content="▶" Style="{StaticResource ToolbarBtn}"
Command="{Binding ExecutePipelineCommand}"
ToolTip="执行流水线"/>
<Button Content="■" Style="{StaticResource ToolbarBtn}"
Command="{Binding CancelExecutionCommand}"
ToolTip="取消执行"/>
</StackPanel>
<Button Content="🗑" Style="{StaticResource ToolbarBtn}"
HorizontalAlignment="Right"
Command="{Binding DeletePipelineCommand}"
ToolTip="删除流水线"/>
</Grid>
</Border>
<!-- 状态栏 -->
<Border Grid.Row="5" Background="#F5F5F5" Padding="8,3">
<TextBlock Text="{Binding StatusMessage}"
FontFamily="{StaticResource CsdFont}" FontSize="11"
Foreground="#555" TextTrimming="CharacterEllipsis"/>
</Border>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,37 @@
using System.Windows;
using System.Windows.Controls;
using Prism.Ioc;
using Telerik.Windows.Controls.DragDrop;
using XplorePlane.Models;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
{
public partial class PipelineEditorView : UserControl
{
public PipelineEditorView()
{
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext == null)
DataContext = ContainerLocator.Container.Resolve<PipelineEditorViewModel>();
// 配置拖拽目标:从工具箱拖入算子
RadDragAndDropManager.AddDropInfoHandler(PipelineListBox, OnOperatorDropped);
}
private void OnOperatorDropped(object sender, DragDropEventArgs e)
{
if (DataContext is PipelineEditorViewModel vm
&& e.Options.Payload is OperatorDescriptor descriptor)
{
vm.AddOperatorCommand.Execute(descriptor.Key);
e.Handled = true;
}
}
}
}
@@ -0,0 +1,19 @@
<Window x:Class="XplorePlane.Views.PipelineEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:XplorePlane.Views"
Title="流水线编辑器"
Width="700" Height="750"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="5"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<views:OperatorToolboxView Grid.Column="0"/>
<GridSplitter Grid.Column="1" Width="5" HorizontalAlignment="Stretch" Background="#E0E0E0"/>
<views:PipelineEditorView Grid.Column="2"/>
</Grid>
</Window>
@@ -0,0 +1,12 @@
using System.Windows;
namespace XplorePlane.Views
{
public partial class PipelineEditorWindow : Window
{
public PipelineEditorWindow()
{
InitializeComponent();
}
}
}
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -14,4 +14,6 @@
----------------------
1、主页面的布局与拆分 √
2、硬件层射线源的集成 √
3、图像层集成,包括复刻一个示例界面,优化界面布局及算子中文 √
3、图像层集成,包括复刻一个示例界面,优化界面布局及算子中文 √
4、浮动图像处理工具箱调研
5、各窗体间数据流的传递,全局数据结构的设计