diff --git a/README.md b/README.md
index f1d6278..4ac9fcd 100644
--- a/README.md
+++ b/README.md
@@ -109,3 +109,4 @@ dotnet build XplorePlane.sln -c Release
- [x] 打通与硬件层的调用流程
- [x] 打通与图像层的调用流程
- [ ] CNC的执行、存储逻辑的开发测试
+- [ ] 涉及到图像校准,矩阵
diff --git a/XP.Hardware.Detector/Config/SimulatedDetectorConfig.cs b/XP.Hardware.Detector/Config/SimulatedDetectorConfig.cs
index 8650451..5587742 100644
--- a/XP.Hardware.Detector/Config/SimulatedDetectorConfig.cs
+++ b/XP.Hardware.Detector/Config/SimulatedDetectorConfig.cs
@@ -15,12 +15,12 @@ namespace XP.Hardware.Detector.Config
///
/// 合成帧宽度(像素)| Synthetic frame width (pixels)
///
- public int Width { get; set; } = 256;
+ public int Width { get; set; } = 2800;
///
/// 合成帧高度(像素)| Synthetic frame height (pixels)
///
- public int Height { get; set; } = 256;
+ public int Height { get; set; } = 2800;
///
/// 模拟帧率(帧/秒)| Simulated frame rate (fps)
diff --git a/XP.Hardware.Detector/Implementations/SimulatedDetector.cs b/XP.Hardware.Detector/Implementations/SimulatedDetector.cs
index 5da1122..33c5bc8 100644
--- a/XP.Hardware.Detector/Implementations/SimulatedDetector.cs
+++ b/XP.Hardware.Detector/Implementations/SimulatedDetector.cs
@@ -163,7 +163,7 @@ namespace XP.Hardware.Detector.Implementations
ExposureTime = 0
};
- _logger?.Info("[SimulatedDetector] 发布合成帧 #{N},分辨率 {W}x{H}", n, w, h);
+ //_logger?.Info("[SimulatedDetector] 发布合成帧 #{N},分辨率 {W}x{H}", n, w, h);
PublishImageCaptured(args);
}
diff --git a/XP.Hardware.MotionControl/Implementations/SimulatedLinearAxis.cs b/XP.Hardware.MotionControl/Implementations/SimulatedLinearAxis.cs
index d2cd41b..92c4ea6 100644
--- a/XP.Hardware.MotionControl/Implementations/SimulatedLinearAxis.cs
+++ b/XP.Hardware.MotionControl/Implementations/SimulatedLinearAxis.cs
@@ -30,7 +30,7 @@ namespace XP.Hardware.MotionControl.Implementations
/// 最大位置(mm)| Maximum position (mm)
/// 原点偏移(mm)| Origin offset (mm)
/// 默认速度(mm/s)| Default speed (mm/s)
- public SimulatedLinearAxis(AxisId axisId, double min, double max, double origin, double defaultSpeed = 50.0)
+ public SimulatedLinearAxis(AxisId axisId, double min, double max, double origin, double defaultSpeed = 5.0)
: base(axisId, min, max, origin)
{
_defaultSpeed = defaultSpeed;
diff --git a/XplorePlane/Doc/CNC执行机制说明.md b/XplorePlane/Doc/CNC执行机制说明.md
new file mode 100644
index 0000000..5ce71a8
--- /dev/null
+++ b/XplorePlane/Doc/CNC执行机制说明.md
@@ -0,0 +1,269 @@
+# CNC 执行机制说明
+
+## 概述
+
+`CncExecutionService` 是 CNC 程序的核心执行引擎,负责按节点顺序执行整个检测程序。它协调运动控制、图像采集、流水线处理和结果归档等子系统,实现自动化多位置检测流程。
+
+---
+
+## 类定义
+
+| 类 | 文件 | 职责 |
+|----|------|------|
+| `CncExecutionService` | `Services/Cnc/CncExecutionService.cs` | CNC 程序执行引擎 |
+| `ICncExecutionService` | `Services/Cnc/ICncExecutionService.cs` | 执行服务接口 |
+| `CncEditorViewModel` | `ViewModels/Cnc/CncEditorViewModel.cs` | 调用执行服务的 ViewModel |
+
+---
+
+## 依赖注入
+
+`CncExecutionService` 通过构造函数注入以下服务:
+
+| 依赖 | 接口 | 用途 |
+|------|------|------|
+| 检测结果存储 | `IInspectionResultStore` | 归档检测运行记录和节点结果 |
+| 日志 | `ILoggerService` | 结构化日志记录 |
+| 主视口 | `IMainViewportService` | 获取实时图像、推送结果图像 |
+| 应用状态 | `IAppStateService` | 获取探测器帧数据 |
+| 流水线执行 | `IPipelineExecutionService` | 执行图像处理流水线 |
+| 图像处理 | `IImageProcessingService` | 获取算子定义和参数 |
+| 事件聚合器 | `IEventAggregator` | 订阅探测器断连事件 |
+| 图像持久化 | `IImagePersistenceService` | 保存采集图像到磁盘 |
+| 运动控制 | `IMotionControlService`(可选) | 控制各轴移动到目标位置 |
+| 运动系统 | `IMotionSystem`(可选) | 查询轴状态(是否到位) |
+| 射线源 | `IRaySourceService`(可选) | 射线源控制 |
+
+---
+
+## 执行流程
+
+### 入口方法
+
+```csharp
+Task ExecuteAsync(CncProgram program, IProgress progress, CancellationToken cancellationToken)
+```
+
+### 执行阶段
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 1. 初始化阶段 │
+│ - 创建 LinkedCancellationTokenSource(支持探测器断连取消) │
+│ - 获取初始源图像 │
+│ - 调用 _store.BeginRunAsync() 创建检测运行记录 │
+├─────────────────────────────────────────────────────────────┤
+│ 2. 多位置执行循环(SavePositionNode) │
+│ 对每个 SavePositionNode 按顺序执行: │
+│ ┌─────────────────────────────────────────────────────┐ │
+│ │ Step 0: 运动到目标位置 (MoveToPositionAsync) │ │
+│ │ Step 1: 图像采集(探测器实时帧 或 手动图像文件) │ │
+│ │ Step 2: 图像保存(如果 SaveImage=true) │ │
+│ │ Step 3: 流水线执行(如果下一节点是 InspectionModule) │ │
+│ │ Step 4: 报告节点执行状态 │ │
+│ └─────────────────────────────────────────────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ 3. 批次结果汇总 │
+│ - 构建 BatchCaptureResult │
+│ - 调用 _imagePersistenceService.WriteSummaryAsync() │
+├─────────────────────────────────────────────────────────────┤
+│ 4. 非位置节点执行 │
+│ 按 Index 顺序处理剩余节点: │
+│ - ReferencePointNode: 记录参考点参数 │
+│ - SaveNodeNode / SaveNodeWithImageNode: 记录设备状态 │
+│ - WaitDelayNode: 延时等待(带进度报告) │
+│ - PauseDialogNode: 弹出暂停对话框 │
+│ - InspectionModuleNode: 执行图像处理流水线 │
+│ - CompleteProgramNode: 标记程序完成,退出循环 │
+├─────────────────────────────────────────────────────────────┤
+│ 5. 完成阶段 │
+│ - 调用 _store.CompleteRunAsync() 标记运行结束 │
+│ - 清理 CancellationTokenSource │
+│ - 通知 MainViewportService CNC 执行结束 │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 核心方法说明
+
+### `ExecuteAsync` — 主执行入口
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `program` | `CncProgram` | 待执行的 CNC 程序(含所有节点) |
+| `progress` | `IProgress` | 进度回调(通知 UI 更新节点状态) |
+| `cancellationToken` | `CancellationToken` | 外部取消令牌(用户点击停止) |
+
+**关键行为:**
+- 使用 `LinkedCancellationTokenSource` 将外部取消和探测器断连事件合并
+- 每个节点执行前检查取消状态
+- 失败节点不中断整体执行(容错设计),仅标记为 Failed 并继续
+
+---
+
+### `MoveToPositionAsync` — 运动控制
+
+```csharp
+private Task MoveToPositionAsync(MotionState target, CancellationToken ct)
+```
+
+- 将 `MotionState` 中的微米(μm)坐标转换为毫米(mm)后发送给运动控制服务
+- 依次移动:StageX → StageY → SourceZ → DetectorZ → DetectorSwing → StageRotation → FixtureRotation
+- 任一轴移动失败立即返回错误
+- 运动服务不可用时返回成功(优雅降级)
+
+---
+
+### `WaitForAxesSettledAsync` — 等待轴到位
+
+```csharp
+private async Task WaitForAxesSettledAsync(CancellationToken ct)
+```
+
+- 每 50ms 轮询所有线性轴和旋转轴的状态
+- 所有轴 `Status == Idle` 时返回 `true`
+- 超时 30 秒返回 `false`(不中断执行,继续后续步骤)
+
+---
+
+### `TryGetSourceImage` — 图像获取
+
+```csharp
+private BitmapSource TryGetSourceImage()
+```
+
+**图像源优先级:**
+1. `MainViewportService.LatestManualImage`(用户手动加载的图像)
+2. `MainViewportService.CurrentDisplayImage`(当前显示的实时图像)
+3. `AppStateService.LatestDetectorFrame`(原始探测器帧,Gray16 → BitmapSource)
+
+---
+
+### `ExecuteInspectionNodeAsync` — 检测模块执行
+
+```csharp
+private async Task ExecuteInspectionNodeAsync(
+ Guid runId, InspectionModuleNode inspectionNode, BitmapSource sourceImage, CancellationToken ct)
+```
+
+**执行步骤:**
+1. 创建 `InspectionNodeResult` 记录
+2. 序列化 Pipeline 定义为 JSON 快照
+3. 保存输入图像为资产
+4. 调用 `BuildPipelineNodeViewModels()` 构建流水线 ViewModel
+5. 调用 `_pipelineExecutionService.ExecutePipelineAsync()` 执行流水线
+6. 推送结果图像到主视口
+7. 推送检测叠加层数据(轮廓、标注)
+8. 合成背景图 + 叠加层,保存结果截图
+9. 调用 `_store.AppendNodeResultAsync()` 归档节点结果
+
+---
+
+### `BuildPipelineNodeViewModels` — 构建流水线 ViewModel
+
+```csharp
+private IEnumerable BuildPipelineNodeViewModels(PipelineModel pipeline)
+```
+
+- 将 `PipelineModel.Nodes`(数据模型)转换为 `PipelineNodeViewModel`(执行模型)
+- 加载每个算子的参数定义和保存值
+- 处理 JSON 参数值的类型转换(`JsonElement` → `int/double/bool/string`)
+
+---
+
+### `ExecuteWaitDelayWithProgressAsync` — 延时等待
+
+```csharp
+private static async Task ExecuteWaitDelayWithProgressAsync(
+ WaitDelayNode waitNode, IProgress progress, CancellationToken ct)
+```
+
+- 每 50ms 报告一次进度百分比
+- 支持取消(抛出 `OperationCanceledException`)
+
+---
+
+## 取消机制
+
+| 取消源 | 触发方式 | 处理 |
+|--------|----------|------|
+| 用户点击停止 | `CancellationToken` 从 ViewModel 传入 | 各节点执行前检查 |
+| 探测器断连 | `DetectorDisconnectedEvent` → `_executionCts.Cancel()` | 通过 LinkedCTS 传播 |
+| 运动失败 | `MoveToPositionAsync` 返回失败 | 标记节点 Failed,继续下一个 |
+
+---
+
+## 进度报告
+
+通过 `IProgress` 回调通知 UI:
+
+```csharp
+public record CncNodeExecutionProgress(
+ Guid NodeId,
+ NodeExecutionState State,
+ double? ProgressPercent = null,
+ BitmapSource ResultImage = null,
+ int? PositionIndex = null,
+ int? TotalPositions = null);
+```
+
+ViewModel 收到回调后更新 `CncNodeViewModel.ExecutionState`,触发树形节点的颜色变化。
+
+---
+
+## 结果归档
+
+| 方法 | 时机 | 存储内容 |
+|------|------|----------|
+| `_store.BeginRunAsync()` | 执行开始 | 运行记录 + 源图像 |
+| `_store.AppendNodeResultAsync()` | 每个检测模块执行后 | 节点结果 + Pipeline 快照 + 输入/输出图像 |
+| `_store.CompleteRunAsync()` | 执行结束 | 标记运行完成/失败 |
+| `_imagePersistenceService.WriteSummaryAsync()` | 多位置循环结束 | 批次结果 JSON 摘要 |
+
+---
+
+## 容错设计
+
+- **图像采集失败**:标记节点 Failed,跳过该位置,继续下一个
+- **图像保存失败**:标记节点 Failed,但仍继续执行流水线
+- **流水线执行异常**:捕获异常,标记 Failed,不中断整体执行
+- **运动服务不可用**:返回成功(优雅降级,适用于仿真模式)
+- **探测器断连**:通过事件取消整个执行
+
+---
+
+## 调用关系图
+
+```
+CncEditorViewModel
+ │
+ ├── RunCncCommand → ExecuteRunAsync()
+ │ │
+ │ └── CncExecutionService.ExecuteAsync()
+ │ │
+ │ ├── IInspectionResultStore.BeginRunAsync()
+ │ │
+ │ ├── [循环] SavePositionNode
+ │ │ ├── MoveToPositionAsync() → IMotionControlService
+ │ │ ├── WaitForAxesSettledAsync() → IMotionSystem
+ │ │ ├── TryGetSourceImage() → IMainViewportService / IAppStateService
+ │ │ ├── IImagePersistenceService.SaveImageAsync()
+ │ │ └── ExecuteInspectionNodeAsync()
+ │ │ ├── BuildPipelineNodeViewModels()
+ │ │ ├── IPipelineExecutionService.ExecutePipelineAsync()
+ │ │ ├── IMainViewportService.SetCncResultImage()
+ │ │ ├── IMainViewportService.PushDetectionOverlay()
+ │ │ └── IInspectionResultStore.AppendNodeResultAsync()
+ │ │
+ │ ├── [循环] 非位置节点
+ │ │ ├── WaitDelayNode → ExecuteWaitDelayWithProgressAsync()
+ │ │ ├── PauseDialogNode → MessageBox
+ │ │ ├── InspectionModuleNode → ExecuteInspectionNodeAsync()
+ │ │ └── CompleteProgramNode → 退出
+ │ │
+ │ ├── IImagePersistenceService.WriteSummaryAsync()
+ │ └── IInspectionResultStore.CompleteRunAsync()
+ │
+ └── StopCncCommand → CancellationTokenSource.Cancel()
+```
diff --git a/XplorePlane/Services/Cnc/CncExecutionService.cs b/XplorePlane/Services/Cnc/CncExecutionService.cs
index 671037b..2c1bdcf 100644
--- a/XplorePlane/Services/Cnc/CncExecutionService.cs
+++ b/XplorePlane/Services/Cnc/CncExecutionService.cs
@@ -26,23 +26,27 @@ using XplorePlane.ViewModels;
namespace XplorePlane.Services.Cnc
{
///
+ /// CNC 程序执行服务。
+ /// 按节点顺序执行整个检测程序,协调运动控制、图像采集、流水线处理和结果归档。
/// Executes a CNC program node-by-node, reporting progress and persisting inspection results.
///
public class CncExecutionService : ICncExecutionService
{
- private readonly IInspectionResultStore _store;
- private readonly ILoggerService _logger;
- private readonly IMainViewportService _mainViewportService;
- private readonly IAppStateService _appStateService;
- private readonly IPipelineExecutionService _pipelineExecutionService;
- private readonly IImageProcessingService _imageProcessingService;
- private readonly IEventAggregator _eventAggregator;
- private readonly IImagePersistenceService _imagePersistenceService;
- private readonly IMotionControlService _motionControlService;
- private readonly IMotionSystem _motionSystem;
- private readonly IRaySourceService _raySourceService;
+ // ── 依赖服务 ──────────────────────────────────────────────────────
+ private readonly IInspectionResultStore _store; // 检测结果归档存储
+ private readonly ILoggerService _logger; // 日志服务
+ private readonly IMainViewportService _mainViewportService; // 主视口(获取/推送图像)
+ private readonly IAppStateService _appStateService; // 应用状态(探测器帧)
+ private readonly IPipelineExecutionService _pipelineExecutionService; // 图像处理流水线执行
+ private readonly IImageProcessingService _imageProcessingService; // 算子注册表
+ private readonly IEventAggregator _eventAggregator; // 事件总线
+ private readonly IImagePersistenceService _imagePersistenceService; // 图像文件持久化
+ private readonly IMotionControlService _motionControlService; // 运动控制(可选)
+ private readonly IMotionSystem _motionSystem; // 运动系统状态查询(可选)
+ private readonly IRaySourceService _raySourceService; // 射线源控制(可选)
- // Task 4.2: volatile field so reads/writes are not reordered across threads
+ // 当前执行的取消令牌源(volatile 保证跨线程可见性)
+ // 探测器断连事件通过此字段取消正在执行的程序
private volatile CancellationTokenSource _executionCts;
public CncExecutionService(
@@ -75,7 +79,7 @@ namespace XplorePlane.Services.Cnc
.Subscribe(OnDetectorDisconnected, ThreadOption.BackgroundThread);
}
- // Task 4.3: callback – cancel the running execution when the detector disconnects
+ // 探测器断连回调:取消当前正在执行的 CNC 程序
private void OnDetectorDisconnected()
{
var cts = _executionCts;
@@ -88,9 +92,14 @@ namespace XplorePlane.Services.Cnc
catch (ObjectDisposedException) { }
}
+ ///
+ /// CNC 程序主执行入口。
+ /// 按节点顺序执行:先处理所有 SavePositionNode(多位置循环),再处理其余节点。
+ /// 支持取消(用户停止 + 探测器断连)、进度报告和容错。
+ ///
public async Task ExecuteAsync(CncProgram program, IProgress progress, CancellationToken cancellationToken)
{
- // Task 4.4: create a linked CTS so DetectorDisconnectedEvent can cancel independently
+ // 创建联合取消令牌:外部取消 + 探测器断连事件均可触发
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executionCts = linkedCts;
_mainViewportService?.SetCncRunning(true);
@@ -139,311 +148,137 @@ namespace XplorePlane.Services.Cnc
bool cancelled = false;
bool allSucceeded = true;
BitmapSource lastResultImage = null;
-
- // Task 5.5: Record start time for batch result summary
var startTime = DateTime.UtcNow;
var positionResults = new List();
- // Task 5.1: Get all SavePositionNodes ordered by Index ascending for multi-position execution
- var savePositionNodes = program.Nodes
- .OfType()
- .OrderBy(n => n.Index)
- .ToList();
- int totalPositions = savePositionNodes.Count;
+ // ══════════════════════════════════════════════════════════════
+ // 单循环:按 Index 顺序逐个执行所有节点
+ // ══════════════════════════════════════════════════════════════
+ var allNodes = program.Nodes.OrderBy(n => n.Index).ToList();
- // Task 5.1: Multi-position execution loop with progress reporting and cancellation
- for (int positionIndex = 0; positionIndex < totalPositions; positionIndex++)
+ foreach (var node in allNodes)
{
- // Task 5.1: Check CancellationToken at the start of each iteration
if (linkedCts.Token.IsCancellationRequested)
{
cancelled = true;
_motionControlService?.StopAll();
- _logger.ForModule().Info(
- "Multi-position execution cancelled at position {0}/{1}",
- positionIndex, totalPositions);
break;
}
- var sp = savePositionNodes[positionIndex];
-
- // Task 5.1: Report progress (current 0-based index, total count)
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id,
- NodeExecutionState.Running,
- ProgressPercent: 0,
- PositionIndex: positionIndex,
- TotalPositions: totalPositions));
-
- _logger.ForModule().Info(
- "Executing save-position node [{Index}] {Name} (position {Current}/{Total}) | " +
- "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
- "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
- "StageRotation={StageRotation} FixtureRotation={FixtureRotation} SaveImage={SaveImage}",
- sp.Index, sp.Name, positionIndex + 1, totalPositions,
- sp.MotionState.StageX, sp.MotionState.StageY,
- sp.MotionState.SourceZ, sp.MotionState.DetectorZ,
- sp.MotionState.DetectorSwing, sp.MotionState.FDD,
- sp.MotionState.FOD, sp.MotionState.Magnification,
- sp.MotionState.StageRotation, sp.MotionState.FixtureRotation,
- sp.SaveImage);
-
+ progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0));
bool nodeSucceeded = true;
- string savedImagePath = null;
- string nodeErrorMessage = null;
-
- // ── Step 0: Move to target position (motion integration) ──
- var moveResult = await MoveToPositionAsync(sp.MotionState, linkedCts.Token);
- if (!moveResult.Success)
- {
- _logger.ForModule().Warn(
- "Motion move failed for node '{0}' at index {1}: {2}",
- sp.Name, positionIndex, moveResult.ErrorMessage);
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id, NodeExecutionState.Failed,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
- allSucceeded = false;
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = "Failed",
- ErrorMessage = $"Motion failed: {moveResult.ErrorMessage}"
- });
- continue;
- }
-
- // Wait for all axes to settle
- var settled = await WaitForAxesSettledAsync(linkedCts.Token);
- if (!settled)
- {
- _logger.ForModule().Warn(
- "Axes did not settle within timeout for node '{0}' at index {1}",
- sp.Name, positionIndex);
- // Continue anyway - axes may be close enough
- }
-
- // ── Step 1: Image Acquisition (with error tolerance - Task 5.4) ──
- BitmapSource positionImage = null;
try
{
- if (!string.IsNullOrEmpty(sp.ManualImagePath))
+ switch (node)
{
- // Task 5.2: ManualImagePath is set - validate and load from file
- var validationResult = ManualImageValidator.Validate(sp.ManualImagePath);
-
- if (validationResult == ManualImageValidationResult.Valid)
+ // ── 保存位置节点:运动 → 采集 → 保存图像 ──
+ case SavePositionNode sp:
{
- positionImage = LoadImageFromFile(sp.ManualImagePath);
- if (positionImage != null)
+ _logger.ForModule().Info(
+ "执行位置节点 [{Index}] {Name} | StageX={StageX} StageY={StageY}",
+ sp.Index, sp.Name, sp.MotionState.StageX, sp.MotionState.StageY);
+
+ var moveResult = await MoveToPositionAsync(sp.MotionState, linkedCts.Token);
+ if (!moveResult.Success)
{
- currentSourceImage = positionImage;
- _logger.ForModule().Info(
- "Loaded manual image from '{0}' for node '{1}'",
- sp.ManualImagePath, sp.Name);
+ nodeSucceeded = false;
+ positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Failed", ErrorMessage = moveResult.ErrorMessage });
+ break;
+ }
+ await WaitForAxesSettledAsync(linkedCts.Token);
+
+ BitmapSource positionImage = null;
+ if (!string.IsNullOrEmpty(sp.ManualImagePath))
+ {
+ var vr = ManualImageValidator.Validate(sp.ManualImagePath);
+ if (vr == ManualImageValidationResult.Valid)
+ positionImage = LoadImageFromFile(sp.ManualImagePath);
+ if (positionImage == null) { nodeSucceeded = false; positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Failed", ErrorMessage = "手动图像加载失败" }); break; }
}
else
{
- // Manual image file could not be decoded - mark Failed, continue
- _logger.ForModule().Warn(
- "Image acquisition failed for node '{0}' at index {1}: manual image file could not be decoded",
- sp.Name, positionIndex);
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id, NodeExecutionState.Failed,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
- allSucceeded = false;
- // Task 5.5: Track failed position result
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = "Failed",
- ErrorMessage = "Manual image file could not be decoded"
- });
- continue;
+ positionImage = TryGetSourceImage();
+ if (positionImage == null) { nodeSucceeded = false; positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Failed", ErrorMessage = "探测器无有效帧" }); break; }
}
- }
- else
- {
- // Validation failed - show error dialog, mark Failed, continue
- var errorMessage = validationResult switch
+ currentSourceImage = positionImage;
+
+ if (sp.SaveImage)
{
- ManualImageValidationResult.PathTooLong =>
- $"\u56fe\u50cf\u8def\u5f84\u8fc7\u957f\uff08\u8d85\u8fc7260\u5b57\u7b26\uff09\uff1a\n{sp.ManualImagePath}",
- ManualImageValidationResult.FileNotFound =>
- $"\u56fe\u50cf\u6587\u4ef6\u4e0d\u5b58\u5728\uff1a\n{sp.ManualImagePath}",
- ManualImageValidationResult.UnsupportedFormat =>
- $"\u4e0d\u652f\u6301\u7684\u56fe\u50cf\u683c\u5f0f\uff08\u4ec5\u652f\u6301 BMP\u3001PNG\u3001TIFF\uff09\uff1a\n{sp.ManualImagePath}",
- _ => $"\u56fe\u50cf\u8def\u5f84\u65e0\u6548\uff1a\n{sp.ManualImagePath}"
- };
+ try
+ {
+ var bytes = EncodeBitmapToBmp(positionImage);
+ var sr = await _imagePersistenceService.SaveImageAsync(bytes, sp.Name, program.Name, linkedCts.Token);
+ positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Success", ImagePath = sr.Success ? sr.FilePath : null });
+ }
+ catch { positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Success" }); }
+ }
+ else
+ {
+ positionResults.Add(new PositionResult { NodeName = sp.Name, NodeIndex = sp.Index, Status = "Success" });
+ }
+ break;
+ }
- _logger.ForModule().Warn(
- "Image acquisition failed for node '{0}' at index {1}: manual image validation failed ({2})",
- sp.Name, positionIndex, validationResult);
+ // ── 检测模块节点:使用当前源图像执行流水线 ──
+ case InspectionModuleNode inspectionNode:
+ {
+ if (currentSourceImage == null) { nodeSucceeded = false; break; }
+ try
+ {
+ var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, currentSourceImage, linkedCts.Token);
+ if (img != null) lastResultImage = img;
+ }
+ catch (Exception ex)
+ {
+ _logger.ForModule().Error(ex, "检测模块 '{0}' 执行失败", inspectionNode.Name);
+ nodeSucceeded = false;
+ }
+ break;
+ }
+ case ReferencePointNode rp:
+ _logger.ForModule().Info("执行参考点 [{Index}] {Name}", rp.Index, rp.Name);
+ break;
+
+ case WaitDelayNode waitNode:
+ try { await ExecuteWaitDelayWithProgressAsync(waitNode, progress, linkedCts.Token); }
+ catch (OperationCanceledException) { cancelled = true; }
+ break;
+
+ case PauseDialogNode pauseNode:
await Application.Current.Dispatcher.InvokeAsync(() =>
- MessageBox.Show(errorMessage, "\u624b\u52a8\u56fe\u50cf\u52a0\u8f7d\u5931\u8d25", MessageBoxButton.OK, MessageBoxImage.Error));
+ MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle));
+ if (linkedCts.Token.IsCancellationRequested) cancelled = true;
+ break;
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id, NodeExecutionState.Failed,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
- allSucceeded = false;
- // Task 5.5: Track failed position result
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = "Failed",
- ErrorMessage = $"Manual image validation failed: {validationResult}"
- });
- continue;
- }
- }
- else
- {
- // ManualImagePath is empty - use detector acquisition (default)
- positionImage = TryGetSourceImage();
- if (positionImage == null)
- {
- // Detector returned null - mark Failed, continue
- _logger.ForModule().Warn(
- "Image acquisition failed for node '{0}' at index {1}: detector returned no valid image frame",
- sp.Name, positionIndex);
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id, NodeExecutionState.Failed,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
- allSucceeded = false;
- // Task 5.5: Track failed position result
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = "Failed",
- ErrorMessage = "Detector returned no valid image frame"
- });
- continue;
- }
- currentSourceImage = positionImage;
+ case CompleteProgramNode:
+ progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Succeeded));
+ goto endLoop;
+
+ default:
+ break;
}
}
catch (Exception ex)
{
- // Unexpected exception during image acquisition - mark Failed, continue
- _logger.ForModule().Warn(
- "Image acquisition failed for node '{0}' at index {1}: {2}",
- sp.Name, positionIndex, ex.Message);
- progress?.Report(new CncNodeExecutionProgress(
- sp.Id, NodeExecutionState.Failed,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
- allSucceeded = false;
- // Task 5.5: Track failed position result
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = "Failed",
- ErrorMessage = ex.Message
- });
- continue;
+ _logger.ForModule().Error(ex, "节点 '{0}' 执行异常", node.Name);
+ if (linkedCts.Token.IsCancellationRequested) cancelled = true;
+ else nodeSucceeded = false;
}
- // ── Step 2: Image Persistence (with error tolerance - Task 5.4) ──
- if (sp.SaveImage && positionImage != null)
- {
- try
- {
- var imageBytes = EncodeBitmapToBmp(positionImage);
- var saveResult = await _imagePersistenceService.SaveImageAsync(
- imageBytes, sp.Name, program.Name, linkedCts.Token);
+ if (cancelled) { progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Failed)); break; }
- if (saveResult.Success)
- {
- _logger.ForModule().Info(
- "Image saved for node '{0}': Path={1}, Size={2} bytes",
- sp.Name, saveResult.FilePath, saveResult.FileSizeBytes);
- // Task 5.5: Track saved image path
- savedImagePath = saveResult.FilePath;
- }
- else
- {
- // Save returned failure - log error, mark failed, but continue with pipeline
- _logger.ForModule().Error(
- new InvalidOperationException(saveResult.ErrorMessage),
- "Image save failed for node '{0}': {1}",
- sp.Name, saveResult.ErrorMessage);
- nodeSucceeded = false;
- nodeErrorMessage = saveResult.ErrorMessage;
- }
- }
- catch (Exception ex)
- {
- // Exception during save - log error, mark failed, but continue with pipeline
- _logger.ForModule().Error(ex,
- "Image save failed for node '{0}': {1}",
- sp.Name, ex.Message);
- nodeSucceeded = false;
- nodeErrorMessage = ex.Message;
- }
- // Note: image save failure does NOT prevent pipeline execution
- }
-
- // ── Step 3: Pipeline Execution (with error tolerance - Task 5.4) ──
- // Check if the next node (by Index order) is an InspectionModuleNode
- if (positionImage != null)
- {
- var allNodesOrdered = program.Nodes.OrderBy(n => n.Index).ToList();
- int currentNodeOrderIndex = allNodesOrdered.FindIndex(n => n.Id == sp.Id);
- if (currentNodeOrderIndex >= 0 && currentNodeOrderIndex + 1 < allNodesOrdered.Count)
- {
- var nextNode = allNodesOrdered[currentNodeOrderIndex + 1];
- if (nextNode is InspectionModuleNode inspectionNode)
- {
- try
- {
- _logger.ForModule().Info(
- "Passing captured image from node '{0}' to inspection module '{1}'",
- sp.Name, inspectionNode.Name);
-
- var resultImage = await ExecuteInspectionNodeAsync(
- runId, inspectionNode, positionImage, linkedCts.Token);
- if (resultImage != null)
- lastResultImage = resultImage;
- }
- catch (Exception ex)
- {
- // Pipeline execution exception - log error, mark failed, continue
- _logger.ForModule().Error(ex,
- "Pipeline execution failed for node '{0}' at index {1}: {2}",
- sp.Name, positionIndex, ex.Message);
- nodeSucceeded = false;
- nodeErrorMessage = $"Pipeline execution failed: {ex.Message}";
- }
- }
- }
- }
-
- // ── Step 4: Report final state ──
+ var nodeResultImage = node is InspectionModuleNode ? lastResultImage : null;
var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed;
- progress?.Report(new CncNodeExecutionProgress(sp.Id, finalState,
- PositionIndex: positionIndex, TotalPositions: totalPositions));
-
- // Task 5.5: Track position result
- positionResults.Add(new PositionResult
- {
- NodeName = sp.Name,
- NodeIndex = sp.Index,
- Status = nodeSucceeded ? "Success" : "Failed",
- ErrorMessage = nodeErrorMessage,
- ImagePath = savedImagePath
- });
-
- if (!nodeSucceeded)
- allSucceeded = false;
+ progress?.Report(new CncNodeExecutionProgress(node.Id, finalState, nodeResultImage));
+ if (!nodeSucceeded) allSucceeded = false;
}
- // ── Task 5.5: Build BatchCaptureResult and write summary ──
- var wasCancelled = cancelled;
+ endLoop:
+
+ // ── 写入批次结果摘要 ──
+ int totalPositions = program.Nodes.OfType().Count();
var batchResult = new BatchCaptureResult
{
ProgramName = program.Name,
@@ -453,175 +288,18 @@ namespace XplorePlane.Services.Cnc
SucceededPositions = positionResults.Count(r => r.Status == "Success"),
FailedPositions = positionResults.Count(r => r.Status == "Failed"),
SavedImageCount = positionResults.Count(r => r.ImagePath != null),
- Status = wasCancelled ? "Cancelled" : "Completed",
- CompletedBeforeCancel = wasCancelled ? positionResults.Count : null,
- NotExecutedAfterCancel = wasCancelled ? totalPositions - positionResults.Count : null,
+ Status = cancelled ? "Cancelled" : "Completed",
+ CompletedBeforeCancel = cancelled ? positionResults.Count : null,
+ NotExecutedAfterCancel = cancelled ? totalPositions - positionResults.Count : null,
Positions = positionResults
};
- try
- {
- var summaryWritten = await _imagePersistenceService.WriteSummaryAsync(
- batchResult, program.Name, CancellationToken.None);
- if (!summaryWritten)
- {
- _logger.ForModule().Error(
- null,
- "Failed to write batch capture summary for program '{0}'",
- program.Name);
- }
- }
- catch (Exception ex)
- {
- _logger.ForModule().Error(ex,
- "Failed to write batch capture summary for program '{0}': {1}",
- program.Name, ex.Message);
- }
-
- // Process remaining non-SavePosition nodes in order
- foreach (var node in program.Nodes.OrderBy(n => n.Index))
- {
- if (linkedCts.Token.IsCancellationRequested)
- {
- cancelled = true;
- break;
- }
-
- // Skip SavePositionNodes - already processed in multi-position loop above
- if (node is SavePositionNode)
- continue;
-
- progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Running, ProgressPercent: 0));
-
- bool nodeSucceeded = true;
-
- try
- {
- switch (node)
- {
- case ReferencePointNode rp:
- _logger.ForModule().Info(
- "Executing reference point node [{Index}] {Name} | " +
- "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
- "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
- "StageRotation={StageRotation} FixtureRotation={FixtureRotation} " +
- "RayOn={RayOn} Voltage={Voltage}kV Current={Current}uA",
- rp.Index, rp.Name,
- rp.StageX, rp.StageY, rp.SourceZ, rp.DetectorZ,
- rp.DetectorSwing, rp.FDD, rp.FOD, rp.Magnification,
- rp.StageRotation, rp.FixtureRotation,
- rp.IsRayOn, rp.Voltage, rp.Current);
- break;
-
- case SaveNodeNode sn:
- _logger.ForModule().Info(
- "Executing save node [{Index}] {Name} | " +
- "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
- "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
- "RayOn={RayOn} Voltage={Voltage}kV Power={Power}W",
- sn.Index, sn.Name,
- sn.MotionState.StageX, sn.MotionState.StageY,
- sn.MotionState.SourceZ, sn.MotionState.DetectorZ,
- sn.MotionState.DetectorSwing, sn.MotionState.FDD,
- sn.MotionState.FOD, sn.MotionState.Magnification,
- sn.RaySourceState.IsOn, sn.RaySourceState.Voltage, sn.RaySourceState.Power);
- break;
-
- case SaveNodeWithImageNode sni:
- _logger.ForModule().Info(
- "Executing save-with-image node [{Index}] {Name} | " +
- "StageX={StageX} StageY={StageY} SourceZ={SourceZ} DetectorZ={DetectorZ} " +
- "DetectorSwing={DetectorSwing} FDD={FDD} FOD={FOD} Magnification={Magnification} " +
- "RayOn={RayOn} Voltage={Voltage}kV Power={Power}W ImageFile={ImageFile}",
- sni.Index, sni.Name,
- sni.MotionState.StageX, sni.MotionState.StageY,
- sni.MotionState.SourceZ, sni.MotionState.DetectorZ,
- sni.MotionState.DetectorSwing, sni.MotionState.FDD,
- sni.MotionState.FOD, sni.MotionState.Magnification,
- sni.RaySourceState.IsOn, sni.RaySourceState.Voltage, sni.RaySourceState.Power,
- sni.ImageFileName);
- break;
-
- case WaitDelayNode waitNode:
- try
- {
- await ExecuteWaitDelayWithProgressAsync(waitNode, progress, linkedCts.Token);
- }
- catch (OperationCanceledException)
- {
- cancelled = true;
- }
- break;
-
- case PauseDialogNode pauseNode:
- await Application.Current.Dispatcher.InvokeAsync(() =>
- MessageBox.Show(pauseNode.DialogMessage, pauseNode.DialogTitle));
- if (linkedCts.Token.IsCancellationRequested)
- cancelled = true;
- break;
-
- case InspectionModuleNode inspectionNode:
- try
- {
- var img = await ExecuteInspectionNodeAsync(runId, inspectionNode, currentSourceImage, linkedCts.Token);
- if (img != null) lastResultImage = img;
- }
- catch (Exception ex)
- {
- _logger.ForModule().Error(ex,
- "Failed to append node result for node '{0}' (Id={1})", inspectionNode.Name, inspectionNode.Id);
- nodeSucceeded = false;
- }
- break;
-
- case CompleteProgramNode:
- // Report Succeeded before terminating the loop
- progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Succeeded));
- goto endLoop;
-
- default:
- // Unknown node types are treated as succeeded
- break;
- }
- }
- catch (Exception ex)
- {
- _logger.ForModule().Error(ex,
- "Unexpected error executing node '{0}' (Id={1})", node.Name, node.Id);
- if (linkedCts.Token.IsCancellationRequested)
- cancelled = true;
- else
- nodeSucceeded = false;
- }
-
- if (cancelled)
- {
- progress?.Report(new CncNodeExecutionProgress(node.Id, NodeExecutionState.Failed));
- break;
- }
-
- // Carry the latest inspection result image so the ViewModel can cache it on the node.
- var nodeResultImage = node is InspectionModuleNode ? lastResultImage : null;
- var finalState = nodeSucceeded ? NodeExecutionState.Succeeded : NodeExecutionState.Failed;
- progress?.Report(new CncNodeExecutionProgress(node.Id, finalState, nodeResultImage));
-
- if (!nodeSucceeded)
- allSucceeded = false;
- }
-
- endLoop:
+ try { await _imagePersistenceService.WriteSummaryAsync(batchResult, program.Name, CancellationToken.None); }
+ catch (Exception ex) { _logger.ForModule().Error(ex, "写入批次摘要失败"); }
bool? overallPass = cancelled ? null : (bool?)allSucceeded;
-
- try
- {
- await _store.CompleteRunAsync(runId, overallPass);
- }
- catch (Exception ex)
- {
- _logger.ForModule().Error(ex,
- "Failed to complete inspection run '{0}'", runId);
- }
+ try { await _store.CompleteRunAsync(runId, overallPass); }
+ catch (Exception ex) { _logger.ForModule().Error(ex, "完成检测运行记录失败"); }
} // end try
finally
{
@@ -631,9 +309,10 @@ namespace XplorePlane.Services.Cnc
}
///
- /// Moves all axes to the target position specified by the MotionState.
- /// MotionState positions are in micrometers (μm); ILinearAxis uses millimeters (mm).
- /// Returns MotionResult.Ok() if motion service is not available (graceful degradation).
+ /// 将所有轴移动到目标位置。
+ /// MotionState 中的坐标单位为微米(μm),运动控制接口使用毫米(mm),需除以 1000。
+ /// 旋转轴单位为度(°),无需转换。
+ /// 运动服务不可用时返回成功(优雅降级,适用于仿真模式)。
///
private Task MoveToPositionAsync(MotionState target, CancellationToken ct)
{
@@ -654,22 +333,26 @@ namespace XplorePlane.Services.Cnc
if (!detectorZResult.Success) return Task.FromResult(detectorZResult);
// Rotary axes: angles are already in degrees, no conversion needed
+ // 禁用轴返回失败时仅记录警告,不中断执行
var detectorSwingResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.DetectorSwing, target.DetectorSwing);
- if (!detectorSwingResult.Success) return Task.FromResult(detectorSwingResult);
+ if (!detectorSwingResult.Success)
+ _logger.ForModule().Warn("旋转轴 DetectorSwing 移动跳过:{0}", detectorSwingResult.ErrorMessage);
var stageRotationResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.StageRotation, target.StageRotation);
- if (!stageRotationResult.Success) return Task.FromResult(stageRotationResult);
+ if (!stageRotationResult.Success)
+ _logger.ForModule().Warn("旋转轴 StageRotation 移动跳过:{0}", stageRotationResult.ErrorMessage);
var fixtureRotationResult = _motionControlService.MoveRotaryToTarget(RotaryAxisId.FixtureRotation, target.FixtureRotation);
- if (!fixtureRotationResult.Success) return Task.FromResult(fixtureRotationResult);
+ if (!fixtureRotationResult.Success)
+ _logger.ForModule().Warn("旋转轴 FixtureRotation 移动跳过:{0}", fixtureRotationResult.ErrorMessage);
return Task.FromResult(MotionResult.Ok());
}
///
- /// Polls all axes until they are settled (Status == Idle).
- /// Returns true when all axes are idle, false on timeout (30s).
- /// Returns true immediately if motion system is not available (graceful degradation).
+ /// 轮询所有轴状态,等待全部到位(Status == Idle)。
+ /// 每 50ms 检查一次,超时 30 秒返回 false。
+ /// 运动系统不可用时直接返回 true(优雅降级)。
///
private async Task WaitForAxesSettledAsync(CancellationToken ct)
{
@@ -708,23 +391,34 @@ namespace XplorePlane.Services.Cnc
}
if (allIdle)
+ {
+ _logger.ForModule().Info(
+ "所有轴已到位,等待耗时 {0}ms", sw.ElapsedMilliseconds);
return true;
+ }
await Task.Delay(pollIntervalMs, ct);
}
+ _logger.ForModule().Warn(
+ "等待轴到位超时({0}ms),继续执行", timeoutMs);
return false;
}
+ ///
+ /// 获取当前可用的源图像。
+ /// 优先级:手动加载图像 > 主视口显示图像 > 探测器原始帧(Gray16 转 BitmapSource)。
+ /// 所有源均不可用时返回 null。
+ ///
private BitmapSource TryGetSourceImage()
{
// ── 优先级 1:MainViewportService 中的手动图像或当前显示图像 ──
var manualImage = _mainViewportService?.LatestManualImage as BitmapSource;
if (manualImage != null)
{
- _logger.ForModule().Info(
- "[图像链路] TryGetSourceImage:从 MainViewportService.LatestManualImage 获取图像,尺寸 {W}x{H}",
- manualImage.PixelWidth, manualImage.PixelHeight);
+ //_logger.ForModule().Info(
+ // "[图像链路] TryGetSourceImage:从 MainViewportService.LatestManualImage 获取图像,尺寸 {W}x{H}",
+ // manualImage.PixelWidth, manualImage.PixelHeight);
return manualImage;
}
@@ -765,7 +459,7 @@ namespace XplorePlane.Services.Cnc
}
///
- /// Loads a BitmapSource from a local file path. Returns null if loading fails.
+ /// 从本地文件加载图像。加载失败返回 null。
///
private static BitmapSource LoadImageFromFile(string filePath)
{
@@ -785,6 +479,10 @@ namespace XplorePlane.Services.Cnc
}
}
+ ///
+ /// 执行检测模块节点:运行图像处理流水线,保存输入/输出图像,归档节点结果。
+ /// 返回流水线输出图像(用于 UI 显示和后续节点),执行失败时返回 null。
+ ///
private async Task ExecuteInspectionNodeAsync(
Guid runId,
InspectionModuleNode inspectionNode,
@@ -828,6 +526,7 @@ namespace XplorePlane.Services.Cnc
}
BitmapSource resultImage = null;
+ IReadOnlyDictionary lastOutputData = null;
if (_pipelineExecutionService != null && inspectionNode.Pipeline?.Nodes?.Count > 0 && sourceImage != null)
{
try
@@ -836,6 +535,7 @@ namespace XplorePlane.Services.Cnc
var execResult = await _pipelineExecutionService.ExecutePipelineAsync(
pipelineNodes, sourceImage, null, cancellationToken);
resultImage = execResult.Image;
+ lastOutputData = execResult.LastStepOutputData;
if (resultImage != null)
{
@@ -909,10 +609,74 @@ namespace XplorePlane.Services.Cnc
}
}
- await _store.AppendNodeResultAsync(nodeResult, pipelineSnapshot: pipelineSnapshot, assets: assets);
+ await _store.AppendNodeResultAsync(nodeResult, metrics: ExtractMetrics(runId, inspectionNode, lastOutputData), pipelineSnapshot: pipelineSnapshot, assets: assets);
return resultImage;
}
+ ///
+ /// 从流水线最后一步的 OutputData 中提取检测指标(BGA空洞率、孔隙测量等)。
+ /// 写入 manifest.json 的 Metrics 部分。
+ ///
+ private static List ExtractMetrics(
+ Guid runId,
+ InspectionModuleNode inspectionNode,
+ IReadOnlyDictionary outputData)
+ {
+ var metrics = new List();
+ if (outputData == null) return metrics;
+
+ var nodeId = inspectionNode.Id;
+ int order = 0;
+
+ // ── BGA 空洞率检测指标 ──
+ if (outputData.ContainsKey("BgaVoidResult"))
+ {
+ if (outputData.TryGetValue("BgaCount", out var bgaCount))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "BgaCount", MetricName = "BGA焊球数", MetricValue = Convert.ToDouble(bgaCount), Unit = "个", IsPass = true, DisplayOrder = order++ });
+
+ if (outputData.TryGetValue("VoidRate", out var voidRate))
+ {
+ var rate = Convert.ToDouble(voidRate);
+ var limit = outputData.TryGetValue("VoidLimit", out var vl) ? Convert.ToDouble(vl) : 50.0;
+ var classification = outputData.TryGetValue("Classification", out var cls) ? cls?.ToString() : "";
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "BgaVoidRate", MetricName = "BGA空洞率", MetricValue = Math.Round(rate, 2), Unit = "%", UpperLimit = limit, IsPass = classification == "PASS", DisplayOrder = order++ });
+ }
+
+ if (outputData.TryGetValue("FillRate", out var fillRate))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "BgaFillRate", MetricName = "BGA填充率", MetricValue = Math.Round(Convert.ToDouble(fillRate), 2), Unit = "%", IsPass = true, DisplayOrder = order++ });
+
+ if (outputData.TryGetValue("TotalVoidCount", out var voidCount))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "BgaTotalVoidCount", MetricName = "气泡总数", MetricValue = Convert.ToDouble(voidCount), Unit = "个", IsPass = true, DisplayOrder = order++ });
+ }
+
+ // ── 孔隙测量指标 ──
+ if (outputData.ContainsKey("VoidMeasurementResult"))
+ {
+ if (outputData.TryGetValue("VoidRate", out var voidRate))
+ {
+ var rate = Convert.ToDouble(voidRate);
+ var limit = outputData.TryGetValue("VoidLimit", out var vl) ? Convert.ToDouble(vl) : 50.0;
+ var classification = outputData.TryGetValue("Classification", out var cls) ? cls?.ToString() : "";
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "VoidRate", MetricName = "空隙率", MetricValue = Math.Round(rate, 2), Unit = "%", UpperLimit = limit, IsPass = classification == "PASS", DisplayOrder = order++ });
+ }
+
+ if (outputData.TryGetValue("VoidCount", out var voidCount))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "VoidCount", MetricName = "空隙数量", MetricValue = Convert.ToDouble(voidCount), Unit = "个", IsPass = true, DisplayOrder = order++ });
+
+ if (outputData.TryGetValue("MaxVoidArea", out var maxArea))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "MaxVoidArea", MetricName = "最大空隙面积", MetricValue = Math.Round(Convert.ToDouble(maxArea), 1), Unit = "px²", IsPass = true, DisplayOrder = order++ });
+
+ if (outputData.TryGetValue("RoiArea", out var roiArea))
+ metrics.Add(new InspectionMetricResult { RunId = runId, NodeId = nodeId, MetricKey = "RoiArea", MetricName = "ROI面积", MetricValue = Math.Round(Convert.ToDouble(roiArea), 1), Unit = "px²", IsPass = true, DisplayOrder = order++ });
+ }
+
+ return metrics;
+ }
+
+ ///
+ /// 将 PipelineModel(数据模型)转换为 PipelineNodeViewModel 列表(执行模型)。
+ /// 加载每个算子的参数定义和保存值,处理 JsonElement 类型转换。
+ ///
private System.Collections.Generic.IEnumerable BuildPipelineNodeViewModels(PipelineModel pipeline)
{
var nodes = new System.Collections.Generic.List();
@@ -947,6 +711,10 @@ namespace XplorePlane.Services.Cnc
return nodes;
}
+ ///
+ /// 将 JsonElement 转换为目标类型(int/double/bool/string)。
+ /// 用于反序列化流水线参数值。
+ ///
private static object ConvertSavedValue(object savedValue, Type targetType)
{
if (savedValue is not JsonElement jsonElement)
@@ -966,6 +734,9 @@ namespace XplorePlane.Services.Cnc
}
}
+ ///
+ /// 将 BitmapSource 编码为 BMP 格式字节数组(用于持久化存储)。
+ ///
private static byte[] EncodeBitmapToBmp(BitmapSource bitmap)
{
using var ms = new MemoryStream();
@@ -975,6 +746,10 @@ namespace XplorePlane.Services.Cnc
return ms.ToArray();
}
+ ///
+ /// 执行延时等待节点,每 50ms 报告一次进度百分比。
+ /// 支持取消(抛出 OperationCanceledException)。
+ ///
private static async Task ExecuteWaitDelayWithProgressAsync(
WaitDelayNode waitNode,
IProgress progress,
diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs
index 01edd34..44ee7d3 100644
--- a/XplorePlane/Services/Cnc/CncProgramService.cs
+++ b/XplorePlane/Services/Cnc/CncProgramService.cs
@@ -374,8 +374,8 @@ namespace XplorePlane.Services.Cnc
result.Add(indexedNode switch
{
ReferencePointNode referencePointNode => referencePointNode with { Name = $"\u53C2\u8003\u70B9_{referencePointNumber++}" },
- SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4FDD\u5B58\u4F4D\u7F6E_{savePositionNumber++}" },
- InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u68C0\u6D4B\u6A21\u5757_{inspectionModuleNumber++}" },
+ SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4F4D\u7F6E{++savePositionNumber}" },
+ InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u6A21\u5757{++inspectionModuleNumber}" },
_ => indexedNode
});
}
@@ -389,19 +389,19 @@ namespace XplorePlane.Services.Cnc
var raySource = _appStateService.RaySourceState;
return new ReferencePointNode(
id, index, $"参考点_{index}",
- StageX: motion.StageX,
- StageY: motion.StageY,
- SourceZ: motion.SourceZ,
- DetectorZ: motion.DetectorZ,
- DetectorSwing: motion.DetectorSwing,
- FDD: motion.FDD,
+ StageX: Math.Round(motion.StageX, 3),
+ StageY: Math.Round(motion.StageY, 3),
+ SourceZ: Math.Round(motion.SourceZ, 3),
+ DetectorZ: Math.Round(motion.DetectorZ, 3),
+ DetectorSwing: Math.Round(motion.DetectorSwing, 3),
+ FDD: Math.Round(motion.FDD, 3),
IsRayOn: raySource.IsOn,
- Voltage: raySource.Voltage,
- Current: TryReadCurrent(),
- StageRotation: motion.StageRotation,
- FixtureRotation: motion.FixtureRotation,
- FOD: motion.FOD,
- Magnification: motion.Magnification);
+ Voltage: Math.Round(raySource.Voltage, 3),
+ Current: Math.Round(TryReadCurrent(), 3),
+ StageRotation: Math.Round(motion.StageRotation, 3),
+ FixtureRotation: Math.Round(motion.FixtureRotation, 3),
+ FOD: Math.Round(motion.FOD, 3),
+ Magnification: Math.Round(motion.Magnification, 3));
}
/// 创建保存节点(含图像)| Create save node with image
@@ -409,7 +409,7 @@ namespace XplorePlane.Services.Cnc
{
return new SaveNodeWithImageNode(
id, index, $"保存节点_图像_{index}",
- MotionState: _appStateService.MotionState,
+ MotionState: RoundMotionState(_appStateService.MotionState),
RaySourceState: _appStateService.RaySourceState,
DetectorState: _appStateService.DetectorState,
ImageFileName: "");
@@ -420,7 +420,7 @@ namespace XplorePlane.Services.Cnc
{
return new SaveNodeNode(
id, index, $"保存节点_{index}",
- MotionState: _appStateService.MotionState,
+ MotionState: RoundMotionState(_appStateService.MotionState),
RaySourceState: _appStateService.RaySourceState,
DetectorState: _appStateService.DetectorState);
}
@@ -430,9 +430,30 @@ namespace XplorePlane.Services.Cnc
{
return new SavePositionNode(
id, index, $"检测位置_{index}",
- MotionState: _appStateService.MotionState,
+ MotionState: RoundMotionState(_appStateService.MotionState),
SaveImage: false);
}
+
+ ///
+ /// 将 MotionState 中的位置/角度值四舍五入到 3 位小数。
+ /// 避免浮点精度噪声写入 CNC 程序文件。
+ ///
+ private static MotionState RoundMotionState(MotionState s, int decimals = 3)
+ {
+ return s with
+ {
+ StageX = Math.Round(s.StageX, decimals),
+ StageY = Math.Round(s.StageY, decimals),
+ SourceZ = Math.Round(s.SourceZ, decimals),
+ DetectorZ = Math.Round(s.DetectorZ, decimals),
+ DetectorSwing = Math.Round(s.DetectorSwing, decimals),
+ FDD = Math.Round(s.FDD, decimals),
+ StageRotation = Math.Round(s.StageRotation, decimals),
+ FixtureRotation = Math.Round(s.FixtureRotation, decimals),
+ FOD = Math.Round(s.FOD, decimals),
+ Magnification = Math.Round(s.Magnification, decimals)
+ };
+ }
private double TryReadCurrent()
{
try
diff --git a/XplorePlane/Services/Cnc/ImagePersistenceService.cs b/XplorePlane/Services/Cnc/ImagePersistenceService.cs
index 13e49c6..667dbce 100644
--- a/XplorePlane/Services/Cnc/ImagePersistenceService.cs
+++ b/XplorePlane/Services/Cnc/ImagePersistenceService.cs
@@ -103,7 +103,8 @@ namespace XplorePlane.Services.Cnc
var options = new JsonSerializerOptions
{
- WriteIndented = true
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
var json = JsonSerializer.Serialize(summary, options);
diff --git a/XplorePlane/Services/MainViewport/DetectorFramePipelineService.cs b/XplorePlane/Services/MainViewport/DetectorFramePipelineService.cs
index 03c70f5..54fb4e9 100644
--- a/XplorePlane/Services/MainViewport/DetectorFramePipelineService.cs
+++ b/XplorePlane/Services/MainViewport/DetectorFramePipelineService.cs
@@ -77,11 +77,11 @@ namespace XplorePlane.Services.MainViewport
try
{
- _logger.Info(
- "[图像链路] DetectorFramePipeline 收到帧 #{N},分辨率 {W}x{H},采集时间 {T},当前队列深度 AcquireQ={AQ} ProcessQ={PQ}",
- args.FrameNumber, args.Width, args.Height,
- args.CaptureTime.ToString("HH:mm:ss.fff"),
- AcquireQueueCount, ProcessQueueCount);
+ //_logger.Info(
+ // "[图像链路] DetectorFramePipeline 收到帧 #{N},分辨率 {W}x{H},采集时间 {T},当前队列深度 AcquireQ={AQ} ProcessQ={PQ}",
+ // args.FrameNumber, args.Width, args.Height,
+ // args.CaptureTime.ToString("HH:mm:ss.fff"),
+ // AcquireQueueCount, ProcessQueueCount);
var rawPixels = new ushort[args.ImageData.Length];
Array.Copy(args.ImageData, rawPixels, rawPixels.Length);
@@ -105,7 +105,7 @@ namespace XplorePlane.Services.MainViewport
{
EnqueueBounded(_processQueue, frame, ProcessQueueCapacity, ref _processQueueCount);
_processSignal.Release();
- _logger.Info(
+ _logger.Debug(
"[图像链路] 帧 #{N} 已入处理队列(每 {Every} 帧采样一次),累计接收 {Total} 帧",
args.FrameNumber, ProcessEveryNFrames, sequence);
}
diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
index 5e4ae4d..fd9a1a4 100644
--- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
+++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs
@@ -32,6 +32,7 @@ namespace XplorePlane.ViewModels.Cnc
private readonly ICncExecutionService _cncExecutionService;
private readonly IXpDataPathService _dataPathService;
private readonly IPipelinePersistenceService _pipelinePersistenceService;
+ private readonly IImageProcessingService _imageProcessingService;
private CncProgram _currentProgram;
private ObservableCollection _nodes;
@@ -57,7 +58,8 @@ namespace XplorePlane.ViewModels.Cnc
ILoggerService logger,
ICncExecutionService cncExecutionService,
IXpDataPathService dataPathService,
- IPipelinePersistenceService pipelinePersistenceService)
+ IPipelinePersistenceService pipelinePersistenceService,
+ IImageProcessingService imageProcessingService = null)
{
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
ArgumentNullException.ThrowIfNull(appStateService);
@@ -66,6 +68,7 @@ namespace XplorePlane.ViewModels.Cnc
_cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService));
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_pipelinePersistenceService = pipelinePersistenceService ?? throw new ArgumentNullException(nameof(pipelinePersistenceService));
+ _imageProcessingService = imageProcessingService; // optional — used for pipeline step display names
_nodes = new ObservableCollection();
_treeNodes = new ObservableCollection();
@@ -661,6 +664,12 @@ namespace XplorePlane.ViewModels.Cnc
IsExpanded = expansionState.TryGetValue(node.Id, out var isExpanded) ? isExpanded : true
};
+ // 为检测模块节点填充流水线步骤(三级树节点)
+ if (node is InspectionModuleNode imNode)
+ {
+ vm.SyncPipelineSteps(imNode.Pipeline, _imageProcessingService);
+ }
+
flatNodes.Add(vm);
if (vm.IsSavePosition)
@@ -996,15 +1005,50 @@ namespace XplorePlane.ViewModels.Cnc
result.Add(indexedNode switch
{
ReferencePointNode referencePointNode => referencePointNode with { Name = $"\u53C2\u8003\u70B9_{referencePointNumber++}" },
- SavePositionNode savePositionNode => savePositionNode with { Name = $"\u4FDD\u5B58\u4F4D\u7F6E_{savePositionNumber++}" },
- InspectionModuleNode inspectionModuleNode => inspectionModuleNode with { Name = $"\u68C0\u6D4B\u6A21\u5757_{inspectionModuleNumber++}" },
+ // 保存位置:用户自定义名称时保留,否则用"位置N"(1-based)
+ SavePositionNode savePositionNode => IsDefaultSavePositionName(savePositionNode.Name)
+ ? savePositionNode with { Name = $"\u4F4D\u7F6E{++savePositionNumber}" }
+ : (CncNode)(savePositionNode with { Index = i }),
+ // 检测模块:用户自定义名称时保留,否则用"模块N"(1-based)
+ InspectionModuleNode inspectionModuleNode => IsDefaultInspectionModuleName(inspectionModuleNode.Name)
+ ? inspectionModuleNode with { Name = $"\u6A21\u5757{++inspectionModuleNumber}" }
+ : (CncNode)(inspectionModuleNode with { Index = i }),
_ => indexedNode
});
+ // 无论是否重命名,计数器都要递增以保持后续编号连续
+ if (indexedNode is SavePositionNode sp && !IsDefaultSavePositionName(sp.Name))
+ savePositionNumber++;
+ if (indexedNode is InspectionModuleNode im && !IsDefaultInspectionModuleName(im.Name))
+ inspectionModuleNumber++;
}
return result.AsReadOnly();
}
+ /// 判断是否为系统默认的保存位置名称("位置N" 格式)
+ private static bool IsDefaultSavePositionName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return true;
+ if (name.StartsWith("\u4F4D\u7F6E", StringComparison.Ordinal))
+ return int.TryParse(name[2..], out _);
+ // 兼容旧格式 "保存位置_N"
+ if (name.StartsWith("\u4FDD\u5B58\u4F4D\u7F6E_", StringComparison.Ordinal))
+ return true;
+ return false;
+ }
+
+ /// 判断是否为系统默认的检测模块名称("模块N" 或旧格式 "检测模块_N")
+ private static bool IsDefaultInspectionModuleName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return true;
+ if (name.StartsWith("\u6A21\u5757", StringComparison.Ordinal))
+ return int.TryParse(name[2..], out _);
+ // 兼容旧格式 "检测模块_N"
+ if (name.StartsWith("\u68C0\u6D4B\u6A21\u5757_", StringComparison.Ordinal))
+ return true;
+ return false;
+ }
+
private static bool IsSavePositionChild(CncNodeType type)
{
return type is CncNodeType.InspectionMarker
diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
index 7ee7d2d..f3d49cb 100644
--- a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
+++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs
@@ -3,6 +3,7 @@ using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
+using System.Linq;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using XplorePlane.Models;
@@ -30,10 +31,18 @@ namespace XplorePlane.ViewModels.Cnc
_modelChangedCallback = modelChangedCallback ?? throw new ArgumentNullException(nameof(modelChangedCallback));
_icon = GetIconForNodeType(model.NodeType);
Children = new ObservableCollection();
+ PipelineSteps = new ObservableCollection();
}
public ObservableCollection Children { get; }
+ ///
+ /// 检测模块的流水线步骤列表(三级节点)。
+ /// 仅 IsInspectionModule == true 时有内容。
+ /// Pipeline steps for InspectionModule nodes (3rd-level tree nodes).
+ ///
+ public ObservableCollection PipelineSteps { get; }
+
public CncNode Model => _model;
public Guid Id => _model.Id;
@@ -415,6 +424,7 @@ namespace XplorePlane.ViewModels.Cnc
if (_model is InspectionModuleNode im)
{
UpdateModel(im with { Pipeline = value ?? new PipelineModel() });
+ SyncPipelineSteps(value);
}
}
}
@@ -684,6 +694,7 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(ManualImagePath));
RaisePropertyChanged(nameof(Pipeline));
RaisePropertyChanged(nameof(PipelineName));
+ RaisePropertyChanged(nameof(PipelineSteps));
RaisePropertyChanged(nameof(MarkerType));
RaisePropertyChanged(nameof(MarkerX));
RaisePropertyChanged(nameof(MarkerY));
@@ -699,6 +710,38 @@ namespace XplorePlane.ViewModels.Cnc
RaisePropertyChanged(nameof(ExecutionProgressText));
}
+ ///
+ /// 树形子节点集合:
+ /// - 对于检测模块节点,返回 PipelineSteps(流水线步骤作为三级节点)
+ /// - 对于其他节点,返回 Children(CNC 子节点)
+ /// Tree children: PipelineSteps for InspectionModule nodes, Children otherwise.
+ ///
+ public System.Collections.IEnumerable TreeChildren =>
+ IsInspectionModule ? (System.Collections.IEnumerable)PipelineSteps : Children;
+
+ ///
+ /// 将 PipelineModel 的节点同步到 PipelineSteps 集合(三级树节点)。
+ /// Syncs PipelineModel nodes into the PipelineSteps collection (3rd-level tree nodes).
+ ///
+ public void SyncPipelineSteps(PipelineModel pipeline, Services.IImageProcessingService imageProcessingService = null)
+ {
+ PipelineSteps.Clear();
+ if (pipeline?.Nodes == null)
+ return;
+
+ foreach (var node in pipeline.Nodes.OrderBy(n => n.Order))
+ {
+ // 优先使用服务获取中文显示名,回退到 OperatorKey
+ var displayName = imageProcessingService?.GetProcessorDisplayName(node.OperatorKey)
+ ?? node.OperatorKey;
+ var icon = Services.ProcessorUiMetadata.GetOperatorIcon(node.OperatorKey);
+ PipelineSteps.Add(new CncPipelineStepViewModel(
+ displayName: displayName,
+ isEnabled: node.IsEnabled,
+ iconPath: icon));
+ }
+ }
+
private enum MotionAxis
{
StageX,
diff --git a/XplorePlane/ViewModels/Cnc/CncPipelineStepViewModel.cs b/XplorePlane/ViewModels/Cnc/CncPipelineStepViewModel.cs
new file mode 100644
index 0000000..3537862
--- /dev/null
+++ b/XplorePlane/ViewModels/Cnc/CncPipelineStepViewModel.cs
@@ -0,0 +1,51 @@
+using Prism.Mvvm;
+
+namespace XplorePlane.ViewModels.Cnc
+{
+ ///
+ /// 检测模块流水线步骤的轻量 ViewModel,用于在 CNC 树形结构中作为三级节点显示。
+ /// Lightweight ViewModel for a pipeline step inside an InspectionModule node,
+ /// displayed as a 3rd-level node in the CNC tree.
+ ///
+ public class CncPipelineStepViewModel : BindableBase
+ {
+ private string _displayName;
+ private bool _isEnabled;
+ private string _iconPath;
+
+ public CncPipelineStepViewModel(string displayName, bool isEnabled, string iconPath = null)
+ {
+ _displayName = displayName;
+ _isEnabled = isEnabled;
+ _iconPath = iconPath ?? string.Empty;
+ }
+
+ /// 算子显示名称(中文)| Operator display name
+ public string DisplayName
+ {
+ get => _displayName;
+ set => SetProperty(ref _displayName, value);
+ }
+
+ /// 是否启用 | Whether the step is enabled
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ set
+ {
+ if (SetProperty(ref _isEnabled, value))
+ RaisePropertyChanged(nameof(StateText));
+ }
+ }
+
+ /// 图标路径 | Icon path
+ public string IconPath
+ {
+ get => _iconPath;
+ set => SetProperty(ref _iconPath, value);
+ }
+
+ /// 状态文字(已启用 / 已停用)| State text
+ public string StateText => _isEnabled ? "已启用" : "已停用";
+ }
+}
diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs
index b2648b8..7cab4ef 100644
--- a/XplorePlane/ViewModels/Main/MainViewModel.cs
+++ b/XplorePlane/ViewModels/Main/MainViewModel.cs
@@ -48,7 +48,7 @@ namespace XplorePlane.ViewModels
{
public class MainViewModel : BindableBase
{
- private const double CncEditorHostWidth = 452d;
+ private const double CncEditorHostWidth = 560d;
private readonly ILoggerService _logger;
private readonly IContainerProvider _containerProvider;
private readonly IEventAggregator _eventAggregator;
diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml
index 09cf789..d16833a 100644
--- a/XplorePlane/Views/Cnc/CncPageView.xaml
+++ b/XplorePlane/Views/Cnc/CncPageView.xaml
@@ -20,18 +20,105 @@
-
+
-
-
+
+
Microsoft YaHei UI
+
+
+
+
+
+