Compare commits

9 Commits

Author SHA1 Message Date
LI Wei.lw faf58b2768 已合并 PR 29: TURBO-568,TURBO-569
1.导航相机校准和标定迁移到XP.Camera类;
2.导航相机控制部分去掉按钮操作改为软件启动自动连接;
3.左键双击导航相机图像获取及显示坐标点;
2026-04-21 16:41:41 +08:00
SONG Tian 3d6901f877 已合并 PR 28: Fix EmguCV针对图像宽不是4的倍数的改变bug修复
- 1、Fix EmguCV针对图像宽不是4的倍数的改变bug修复
- 2、另外修复编码格式问题
2026-04-21 16:30:58 +08:00
李伟 188bac53f1 TURBO-568:修复PLC连接和相机连接时序问题导致的内存访问异常 2026-04-21 16:01:45 +08:00
李伟 12882bd1c6 去掉实时影像的Log 2026-04-21 16:00:36 +08:00
TianSong 8189e76492 Fix EmguCV针对图像宽不是4的倍数的改变bug修复 2026-04-21 15:30:07 +08:00
李伟 80e71054f6 TURBO-569:去掉导航相机的控制按钮,改为相机如果存在自动连接自动显示实时影像 2026-04-21 09:18:57 +08:00
李伟 95b9a6a2ae 修复Image<Gray, byte> 内部存储行对齐导致赋值时的长度不匹配引起的异常 2026-04-21 09:17:12 +08:00
李伟 9218384e3f TURBO-569:更新工程结构;将导航相机标定和校准功能迁移到XP.Camera类 2026-04-20 16:10:18 +08:00
李伟 e166eca3d7 TURBO-569:Common增加提示框HexMessageBox 2026-04-20 16:09:05 +08:00
61 changed files with 3450 additions and 3079 deletions
+110 -110
View File
@@ -1,110 +1,110 @@
## XplorePlane 平面CT软件
### 系统目标
XplorePlane 系统用于控制平面 CT 设备的各个子系统(射线源、探测器、运动控制、相机)并完成采集图像的处理与分析,为研发与调试提供统一的软件平台。
### 总体架构
- 客户端框架: WPF + Prism MVVM(目标框架 net8.0-windows
- 图像处理内核: ImageProcessing.Core(算子基类)+ ImageProcessing.Processors(具体算子),基于 EmguCV
- 相机控制: XP.CameraBasler pylon SDK 封装,支持软件触发、参数读写)
- 硬件抽象: XP.Common + XP.Hardware.RaySource(射线源控制)
- 日志: Serilog
- UI 组件: Telerik RadRibbonView、Fluent.Ribbon
### 解决方案结构
```
XplorePlane.sln
├── XplorePlane/ # 主应用程序(WPF)
├── XP.Camera/ # 相机控制库(Basler
├── ImageProcessing/ # 独立图像处理应用
├── ImageProcessing.Core/ # 图像处理算子基类
├── ImageProcessing.Processors/ # 具体算子实现
├── ImageProcessing.Controls/ # 图像显示控件(ImageCanvasControl
├── ImageROIControl/ # ROI 绘制控件
├── XplorePlane.Tests/ # 单元测试
└── ExternalLibraries/ # 外部 DLL 和 ONNX 模型
```
### XplorePlane 主项目结构
```
XplorePlane/
├── App.xaml / App.xaml.cs # 应用入口 + DI 容器配置(AppBootstrapper
├── Views/
│ ├── Main/
│ │ ├── MainWindow.xaml # 主窗口(Telerik Ribbon + 三栏布局)
│ │ ├── NavigationPropertyPanelView.xaml # 相机实时预览面板
│ │ └── MotionControlPanelView.xaml # 运动控制面板
│ ├── Cnc/ # CNC 编辑器 / 矩阵编排视图
│ ├── ImageProcessing/ # 图像处理面板视图
│ └── CameraSettingsWindow.xaml # 相机参数设置对话框
├── ViewModels/
│ ├── Main/
│ │ ├── MainViewModel.cs # 主窗口 ViewModel
│ │ └── NavigationPropertyPanelViewModel.cs # 相机预览 ViewModel
│ ├── Cnc/ # CNC / 矩阵 ViewModel
│ └── ImageProcessing/ # 图像处理 / 流水线 ViewModel
├── Services/
│ ├── AppState/ # 全局状态管理(线程安全)
│ ├── Camera/ # 相机服务
│ ├── Cnc/ # CNC 程序服务
│ ├── Matrix/ # 矩阵编排服务
│ ├── Measurement/ # 测量数据服务
│ ├── Pipeline/ # 流水线执行 / 持久化
│ └── Recipe/ # 检测配方服务
├── Models/ # 数据模型(State、CNC、Matrix、Pipeline 等)
├── Events/ # Prism 事件
├── Libs/
│ ├── Hardware/ # 硬件库 DLLXP.Common、XP.Hardware.RaySource
│ └── Native/ # 原生依赖库
└── Assets/Icons/ # 工具栏图标
```
### 相机集成
相机实时影像集成在主窗口左下角的 NavigationPropertyPanelView 中:
- 连接/断开相机(Basler,通过 ICameraController
- 开始/停止采集(软件触发模式)
- 实时预览(Live View,勾选"实时"复选框)
- 鼠标悬停显示像素坐标
- 相机参数设置对话框(曝光时间、增益、分辨率、像素格式)
- 主界面 Ribbon 硬件栏提供"相机设置"快捷按钮
相机控制逻辑移植自 ImageProcessing 项目,使用 XP.Camera.PixelConverter 进行像素数据转换,通过 Application.Dispatcher.Invoke 保证 UI 线程安全。
### 依赖注入(DI
使用 Prism + DryIoc,在 AppBootstrapper.RegisterTypes() 中统一注册:
- ICameraFactory / ICameraController / ICameraService(单例)
- IRaySourceService / IRaySourceFactory(单例)
- IAppStateService(单例,线程安全状态管理)
- NavigationPropertyPanelViewModel(单例,相机预览共享实例)
- 各 Service 和 ViewModel(按需注册)
### 构建
```bash
# Debug
dotnet build XplorePlane.sln -c Debug
# Release
dotnet build XplorePlane.sln -c Release
```
### TO-DO List
- [x] 软件基于 WPF + Prism 基础的框架
- [x] 日志库的引用(通过 XP.Common.dll
- [x] 按推荐的 DLL 目录结构进行修改
- [x] 通过库依赖的方式调用日志功能
- [x] 界面的布局
- [x] 相机实时影像集成(连接、采集、Live View、像素坐标显示)
- [x] 相机参数设置对话框(曝光、增益、分辨率、像素格式)
- [x] 主界面硬件栏相机设置按钮
- [ ] 打通与硬件层的调用流程
- [ ] 打通与图像层的调用流程
## XplorePlane 平面CT软件
### 系统目标
XplorePlane 系统用于控制平面 CT 设备的各个子系统(射线源、探测器、运动控制、相机)并完成采集图像的处理与分析,为研发与调试提供统一的软件平台。
### 总体架构
- 客户端框架: WPF + Prism MVVM(目标框架 net8.0-windows
- 图像处理内核: ImageProcessing.Core(算子基类)+ ImageProcessing.Processors(具体算子),基于 EmguCV
- 相机控制: XP.CameraBasler pylon SDK 封装,支持软件触发、参数读写)
- 硬件抽象: XP.Common + XP.Hardware.RaySource(射线源控制)
- 日志: Serilog
- UI 组件: Telerik RadRibbonView、Fluent.Ribbon
### 解决方案结构
```
XplorePlane.sln
├── XplorePlane/ # 主应用程序(WPF)
├── XP.Camera/ # 相机控制库(Basler
├── ImageProcessing/ # 独立图像处理应用
├── ImageProcessing.Core/ # 图像处理算子基类
├── ImageProcessing.Processors/ # 具体算子实现
├── ImageProcessing.Controls/ # 图像显示控件(ImageCanvasControl
├── ImageROIControl/ # ROI 绘制控件
├── XplorePlane.Tests/ # 单元测试
└── ExternalLibraries/ # 外部 DLL 和 ONNX 模型
```
### XplorePlane 主项目结构
```
XplorePlane/
├── App.xaml / App.xaml.cs # 应用入口 + DI 容器配置(AppBootstrapper
├── Views/
│ ├── Main/
│ │ ├── MainWindow.xaml # 主窗口(Telerik Ribbon + 三栏布局)
│ │ ├── NavigationPropertyPanelView.xaml # 相机实时预览面板
│ │ └── MotionControlPanelView.xaml # 运动控制面板
│ ├── Cnc/ # CNC 编辑器 / 矩阵编排视图
│ ├── ImageProcessing/ # 图像处理面板视图
│ └── CameraSettingsWindow.xaml # 相机参数设置对话框
├── ViewModels/
│ ├── Main/
│ │ ├── MainViewModel.cs # 主窗口 ViewModel
│ │ └── NavigationPropertyPanelViewModel.cs # 相机预览 ViewModel
│ ├── Cnc/ # CNC / 矩阵 ViewModel
│ └── ImageProcessing/ # 图像处理 / 流水线 ViewModel
├── Services/
│ ├── AppState/ # 全局状态管理(线程安全)
│ ├── Camera/ # 相机服务
│ ├── Cnc/ # CNC 程序服务
│ ├── Matrix/ # 矩阵编排服务
│ ├── Measurement/ # 测量数据服务
│ ├── Pipeline/ # 流水线执行 / 持久化
│ └── Recipe/ # 检测配方服务
├── Models/ # 数据模型(State、CNC、Matrix、Pipeline 等)
├── Events/ # Prism 事件
├── Libs/
│ ├── Hardware/ # 硬件库 DLLXP.Common、XP.Hardware.RaySource
│ └── Native/ # 原生依赖库
└── Assets/Icons/ # 工具栏图标
```
### 相机集成
相机实时影像集成在主窗口左下角的 NavigationPropertyPanelView 中:
- 连接/断开相机(Basler,通过 ICameraController
- 开始/停止采集(软件触发模式)
- 实时预览(Live View,勾选"实时"复选框)
- 鼠标悬停显示像素坐标
- 相机参数设置对话框(曝光时间、增益、分辨率、像素格式)
- 主界面 Ribbon 硬件栏提供"相机设置"快捷按钮
相机控制逻辑移植自 ImageProcessing 项目,使用 XP.Camera.PixelConverter 进行像素数据转换,通过 Application.Dispatcher.Invoke 保证 UI 线程安全。
### 依赖注入(DI
使用 Prism + DryIoc,在 AppBootstrapper.RegisterTypes() 中统一注册:
- ICameraFactory / ICameraController / ICameraService(单例)
- IRaySourceService / IRaySourceFactory(单例)
- IAppStateService(单例,线程安全状态管理)
- NavigationPropertyPanelViewModel(单例,相机预览共享实例)
- 各 Service 和 ViewModel(按需注册)
### 构建
```bash
# Debug
dotnet build XplorePlane.sln -c Debug
# Release
dotnet build XplorePlane.sln -c Release
```
### TO-DO List
- [x] 软件基于 WPF + Prism 基础的框架
- [x] 日志库的引用(通过 XP.Common.dll
- [x] 按推荐的 DLL 目录结构进行修改
- [x] 通过库依赖的方式调用日志功能
- [x] 界面的布局
- [x] 相机实时影像集成(连接、采集、Live View、像素坐标显示)
- [x] 相机参数设置对话框(曝光、增益、分辨率、像素格式)
- [x] 主界面硬件栏相机设置按钮
- [ ] 打通与硬件层的调用流程
- [ ] 打通与图像层的调用流程
@@ -195,7 +195,6 @@ public class BaslerCameraController : ICameraController
}
_camera.ExecuteSoftwareTrigger();
_logger.Debug("Software trigger executed.");
}
catch (TimeoutException)
{
@@ -0,0 +1,8 @@
using XP.Camera.Calibration.Resources;
namespace XP.Camera.Calibration;
public class CalibrationLocalizedStrings
{
public CalibrationResources Resources { get; } = new CalibrationResources();
}
@@ -0,0 +1,201 @@
// ============================================================================
// 文件名: CalibrationProcessor.cs
// 描述: 标定处理器,实现图像坐标系到世界坐标系的转换
// 功能:
// - 基于多点标定计算透视变换矩阵(支持4点及以上)
// - 像素坐标到世界坐标的转换
// - 标定数据的保存和加载(JSON格式)
// - 从CSV文件导入标定点数据
// 算法: 使用DLTDirect Linear Transformation)方法求解单应性矩阵
// ============================================================================
using Emgu.CV;
using System.IO;
using System.Text.Json;
namespace XP.Camera.Calibration;
/// <summary>
/// 九点标定处理器
/// </summary>
public class CalibrationProcessor
{
public class CalibrationPoint
{
public double PixelX { get; set; }
public double PixelY { get; set; }
public double WorldX { get; set; }
public double WorldY { get; set; }
}
public class CalibrationData
{
public List<CalibrationPoint> Points { get; set; } = new List<CalibrationPoint>();
public double[] TransformMatrix { get; set; } = new double[9];
public DateTime CalibrationTime { get; set; }
}
private Matrix<double>? _transformMatrix;
/// <summary>
/// 执行九点标定
/// </summary>
public bool Calibrate(List<CalibrationPoint> points)
{
if (points.Count < 4)
return false;
int n = points.Count;
var A = new Matrix<double>(2 * n, 8);
var b = new Matrix<double>(2 * n, 1);
for (int i = 0; i < n; i++)
{
double u = points[i].PixelX;
double v = points[i].PixelY;
double x = points[i].WorldX;
double y = points[i].WorldY;
A[2 * i, 0] = u;
A[2 * i, 1] = v;
A[2 * i, 2] = 1;
A[2 * i, 3] = 0;
A[2 * i, 4] = 0;
A[2 * i, 5] = 0;
A[2 * i, 6] = -x * u;
A[2 * i, 7] = -x * v;
b[2 * i, 0] = x;
A[2 * i + 1, 0] = 0;
A[2 * i + 1, 1] = 0;
A[2 * i + 1, 2] = 0;
A[2 * i + 1, 3] = u;
A[2 * i + 1, 4] = v;
A[2 * i + 1, 5] = 1;
A[2 * i + 1, 6] = -y * u;
A[2 * i + 1, 7] = -y * v;
b[2 * i + 1, 0] = y;
}
var h = new Matrix<double>(8, 1);
CvInvoke.Solve(A, b, h, Emgu.CV.CvEnum.DecompMethod.Svd);
_transformMatrix = new Matrix<double>(3, 3);
_transformMatrix[0, 0] = h[0, 0];
_transformMatrix[0, 1] = h[1, 0];
_transformMatrix[0, 2] = h[2, 0];
_transformMatrix[1, 0] = h[3, 0];
_transformMatrix[1, 1] = h[4, 0];
_transformMatrix[1, 2] = h[5, 0];
_transformMatrix[2, 0] = h[6, 0];
_transformMatrix[2, 1] = h[7, 0];
_transformMatrix[2, 2] = 1.0;
return true;
}
/// <summary>
/// 像素坐标转世界坐标
/// </summary>
public System.Drawing.PointF PixelToWorld(System.Drawing.PointF pixel)
{
if (_transformMatrix == null)
return pixel;
double u = pixel.X;
double v = pixel.Y;
double w = _transformMatrix[2, 0] * u + _transformMatrix[2, 1] * v + _transformMatrix[2, 2];
double x = (_transformMatrix[0, 0] * u + _transformMatrix[0, 1] * v + _transformMatrix[0, 2]) / w;
double y = (_transformMatrix[1, 0] * u + _transformMatrix[1, 1] * v + _transformMatrix[1, 2]) / w;
return new System.Drawing.PointF((float)x, (float)y);
}
/// <summary>
/// 保存标定文件
/// </summary>
public void SaveCalibration(string filePath, List<CalibrationPoint> points)
{
var data = new CalibrationData
{
Points = points,
TransformMatrix = new double[9],
CalibrationTime = DateTime.Now
};
if (_transformMatrix != null)
{
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
data.TransformMatrix[i * 3 + j] = _transformMatrix[i, j];
}
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
/// <summary>
/// 加载标定文件
/// </summary>
public bool LoadCalibration(string filePath)
{
if (!File.Exists(filePath))
return false;
try
{
var json = File.ReadAllText(filePath);
var data = JsonSerializer.Deserialize<CalibrationData>(json);
if (data == null || data.TransformMatrix == null || data.TransformMatrix.Length != 9)
return false;
_transformMatrix = new Matrix<double>(3, 3);
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
_transformMatrix[i, j] = data.TransformMatrix[i * 3 + j];
return true;
}
catch
{
return false;
}
}
/// <summary>
/// 从CSV文件加载标定点
/// </summary>
public List<CalibrationPoint> LoadPointsFromCsv(string filePath)
{
var points = new List<CalibrationPoint>();
if (!File.Exists(filePath))
return points;
var lines = File.ReadAllLines(filePath);
for (int i = 0; i < lines.Length; i++)
{
if (i == 0 && (lines[i].Contains("PixelX") || lines[i].Contains("像素"))) continue;
var parts = lines[i].Split(',');
if (parts.Length >= 4)
{
if (double.TryParse(parts[0].Trim(), out double px) &&
double.TryParse(parts[1].Trim(), out double py) &&
double.TryParse(parts[2].Trim(), out double wx) &&
double.TryParse(parts[3].Trim(), out double wy))
{
points.Add(new CalibrationPoint
{
PixelX = px,
PixelY = py,
WorldX = wx,
WorldY = wy
});
}
}
}
return points;
}
}
@@ -0,0 +1,221 @@
// ============================================================================
// 文件名: ChessboardCalibrator.cs
// 描述: 棋盘格标定器,实现基于棋盘格的相机内参标定
// 功能:
// - 从多张棋盘格图像中检测角点
// - 计算相机内参矩阵和畸变系数
// - 图像去畸变处理
// - 计算重投影误差评估标定质量
// - 标定结果的保存和加载(JSON格式)
// 算法: 使用 Zhang's 标定方法进行相机标定
// ============================================================================
using Emgu.CV;
using Emgu.CV.CvEnum;
using Emgu.CV.Structure;
using Emgu.CV.Util;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace XP.Camera.Calibration;
public class ChessboardCalibrator
{
public class CalibrationResult
{
public double[][] CameraMatrix { get; set; } = new double[3][];
public double[] DistortionCoeffs { get; set; } = Array.Empty<double>();
public double ReprojectionError { get; set; }
public DateTime CalibrationTime { get; set; }
}
private Mat? _cameraMatrix;
private Mat? _distCoeffs;
private double _reprojectionError;
private List<double> _perImageErrors = new List<double>();
public double ReprojectionError => _reprojectionError;
public List<double> PerImageErrors => _perImageErrors;
// 进度报告委托
public delegate void ProgressReportHandler(int current, int total, string message);
public event ProgressReportHandler? ProgressChanged;
public bool CalibrateFromImages(List<string> imagePaths, int boardWidth, int boardHeight, float squareSize, out string errorMsg)
{
errorMsg = "";
var objectPoints = new VectorOfVectorOfPoint3D32F();
var imagePoints = new VectorOfVectorOfPointF();
var imageSize = new System.Drawing.Size();
var objp = new MCvPoint3D32f[boardWidth * boardHeight];
for (int i = 0; i < boardHeight; i++)
for (int j = 0; j < boardWidth; j++)
objp[i * boardWidth + j] = new MCvPoint3D32f(j * squareSize, i * squareSize, 0);
int validImages = 0;
int totalImages = imagePaths.Count;
// 第一阶段:检测角点
for (int idx = 0; idx < totalImages; idx++)
{
var path = imagePaths[idx];
ProgressChanged?.Invoke(idx + 1, totalImages * 2, $"检测角点 ({idx + 1}/{totalImages})");
var img = CvInvoke.Imread(path, ImreadModes.Grayscale);
if (img.IsEmpty) continue;
imageSize = img.Size;
var corners = new VectorOfPointF();
if (CvInvoke.FindChessboardCorners(img, new System.Drawing.Size(boardWidth, boardHeight), corners))
{
// 亚像素级角点精化
CvInvoke.CornerSubPix(img, corners, new System.Drawing.Size(5, 5), new System.Drawing.Size(-1, -1),
new MCvTermCriteria(30, 0.001));
objectPoints.Push(new VectorOfPoint3D32F(objp));
imagePoints.Push(corners);
validImages++;
}
img.Dispose();
}
if (validImages < 3)
{
errorMsg = $"有效图像不足,需要至少3张,当前{validImages}张";
ProgressChanged?.Invoke(0, 100, "标定失败");
return false;
}
// 第二阶段:执行标定
ProgressChanged?.Invoke(totalImages, totalImages * 2, "执行相机标定...");
_cameraMatrix = new Mat();
_distCoeffs = new Mat();
var rvecs = new VectorOfMat();
var tvecs = new VectorOfMat();
_reprojectionError = CvInvoke.CalibrateCamera(objectPoints, imagePoints, imageSize, _cameraMatrix, _distCoeffs,
rvecs, tvecs, CalibType.Default, new MCvTermCriteria(30, 1e-6));
// 第三阶段:计算每张图像的重投影误差
_perImageErrors.Clear();
for (int i = 0; i < objectPoints.Size; i++)
{
ProgressChanged?.Invoke(totalImages + i + 1, totalImages * 2, $"计算重投影误差 ({i + 1}/{objectPoints.Size})");
var projectedPoints = new VectorOfPointF();
CvInvoke.ProjectPoints(objectPoints[i], rvecs[i], tvecs[i], _cameraMatrix, _distCoeffs, projectedPoints);
double error = 0;
for (int j = 0; j < projectedPoints.Size; j++)
{
var dx = imagePoints[i][j].X - projectedPoints[j].X;
var dy = imagePoints[i][j].Y - projectedPoints[j].Y;
error += Math.Sqrt(dx * dx + dy * dy);
}
_perImageErrors.Add(error / projectedPoints.Size);
}
ProgressChanged?.Invoke(totalImages * 2, totalImages * 2, "标定完成");
return true;
}
public Mat UndistortImage(Mat inputImage)
{
if (_cameraMatrix == null || _distCoeffs == null)
return inputImage;
var output = new Mat();
CvInvoke.Undistort(inputImage, output, _cameraMatrix, _distCoeffs);
return output;
}
public Mat? DrawChessboardCorners(string imagePath, int boardWidth, int boardHeight)
{
var img = CvInvoke.Imread(imagePath);
if (img.IsEmpty) return null;
var gray = new Mat();
CvInvoke.CvtColor(img, gray, ColorConversion.Bgr2Gray);
var corners = new VectorOfPointF();
if (CvInvoke.FindChessboardCorners(gray, new System.Drawing.Size(boardWidth, boardHeight), corners))
{
CvInvoke.CornerSubPix(gray, corners, new System.Drawing.Size(11, 11), new System.Drawing.Size(-1, -1),
new MCvTermCriteria(30, 0.001));
CvInvoke.DrawChessboardCorners(img, new System.Drawing.Size(boardWidth, boardHeight), corners, true);
}
gray.Dispose();
return img;
}
public void SaveCalibration(string filePath)
{
if (_cameraMatrix == null || _distCoeffs == null)
throw new InvalidOperationException("请先执行标定");
var result = new CalibrationResult
{
CameraMatrix = new double[3][],
DistortionCoeffs = new double[_distCoeffs.Rows],
ReprojectionError = _reprojectionError,
CalibrationTime = DateTime.Now
};
for (int i = 0; i < 3; i++)
{
result.CameraMatrix[i] = new double[3];
for (int j = 0; j < 3; j++)
{
double[] data = new double[1];
Marshal.Copy(_cameraMatrix.DataPointer + (i * 3 + j) * 8, data, 0, 1);
result.CameraMatrix[i][j] = data[0];
}
}
for (int i = 0; i < _distCoeffs.Rows; i++)
{
double[] data = new double[1];
Marshal.Copy(_distCoeffs.DataPointer + i * 8, data, 0, 1);
result.DistortionCoeffs[i] = data[0];
}
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(filePath, json);
}
public bool LoadCalibration(string filePath)
{
if (!File.Exists(filePath)) return false;
try
{
var json = File.ReadAllText(filePath);
var result = JsonSerializer.Deserialize<CalibrationResult>(json);
if (result == null) return false;
_reprojectionError = result.ReprojectionError;
_cameraMatrix = new Mat(3, 3, DepthType.Cv64F, 1);
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
Marshal.Copy(new[] { result.CameraMatrix[i][j] }, 0, _cameraMatrix.DataPointer + (i * 3 + j) * 8, 1);
_distCoeffs = new Mat(result.DistortionCoeffs.Length, 1, DepthType.Cv64F, 1);
for (int i = 0; i < result.DistortionCoeffs.Length; i++)
Marshal.Copy(new[] { result.DistortionCoeffs[i] }, 0, _distCoeffs.DataPointer + i * 8, 1);
return true;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,176 @@
<UserControl x:Class="XP.Camera.Calibration.Controls.CalibrationControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:cal="clr-namespace:XP.Camera.Calibration"
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
mc:Ignorable="d"
d:DesignHeight="850"
d:DesignWidth="1400">
<UserControl.Resources>
<cal:CalibrationLocalizedStrings x:Key="LocalizedStrings" />
<SolidColorBrush x:Key="PrimaryColor" Color="#F5F5F5" />
<SolidColorBrush x:Key="AccentColor" Color="#0078D4" />
<SolidColorBrush x:Key="BackgroundColor" Color="#FAFAFA" />
<SolidColorBrush x:Key="SidebarColor" Color="#FFFFFF" />
<SolidColorBrush x:Key="BorderColor" Color="#E1E1E1" />
<SolidColorBrush x:Key="TextColor" Color="#333333" />
<SolidColorBrush x:Key="TextSecondaryColor" Color="#666666" />
<Style x:Key="ToolbarButtonStyle" TargetType="Button">
<Setter Property="Width" Value="90" />
<Setter Property="Height" Value="70" />
<Setter Property="Margin" Value="0,0,8,0" />
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="{StaticResource TextColor}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter Content="{TemplateBinding Tag}"
HorizontalAlignment="Center"
Margin="0,4,0,4" />
<TextBlock Text="{TemplateBinding Content}"
FontSize="12"
HorizontalAlignment="Center"
Foreground="{TemplateBinding Foreground}"
TextWrapping="Wrap"
TextAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#E5F3FF" />
<Setter Property="BorderBrush" Value="{StaticResource AccentColor}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#CCE8FF" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{StaticResource PrimaryColor}" BorderBrush="{StaticResource BorderColor}"
BorderThickness="0,0,0,1" Padding="15,10">
<StackPanel Orientation="Horizontal">
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationLoadImage}"
Command="{Binding LoadImageCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<iconPacks:PackIconMaterial Kind="ImageOutline" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationLoadCsv}"
Command="{Binding LoadCsvCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<iconPacks:PackIconMaterial Kind="FileDelimited" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationExecute}"
Command="{Binding CalibrateCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<iconPacks:PackIconMaterial Kind="Crosshairs" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationSave}"
Command="{Binding SaveCalibrationCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<iconPacks:PackIconMaterial Kind="ContentSave" Width="24" Height="24" />
</Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationLoad}"
Command="{Binding LoadCalibrationCommand}" FontFamily="Segoe UI"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag>
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="24" Height="24" />
</Button.Tag>
</Button>
<CheckBox Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationShowWorld}"
VerticalAlignment="Center" FontFamily="Segoe UI"
IsChecked="{Binding ShowWorldCoordinates}"
Margin="10,0,0,0" FontSize="13" Foreground="{StaticResource TextColor}" />
</StackPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="400" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" BorderBrush="{StaticResource BorderColor}" BorderThickness="0,0,1,0"
Background="{StaticResource SidebarColor}">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationPointList}"
FontSize="15" FontWeight="SemiBold" FontFamily="Segoe UI"
Margin="0,0,0,12" Foreground="{StaticResource TextColor}" />
<DataGrid Grid.Row="1" AutoGenerateColumns="False" CanUserAddRows="True"
ItemsSource="{Binding CalibrationPoints}"
HeadersVisibility="Column" GridLinesVisibility="All"
FontFamily="Segoe UI"
BorderBrush="{StaticResource BorderColor}" BorderThickness="1">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationPixelX}"
Binding="{Binding PixelX}" FontFamily="Segoe UI" Width="*" />
<DataGridTextColumn Header="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationPixelY}"
Binding="{Binding PixelY}" FontFamily="Segoe UI" Width="*" />
<DataGridTextColumn Header="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationWorldX}"
Binding="{Binding WorldX}" FontFamily="Segoe UI" Width="*" />
<DataGridTextColumn Header="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.CalibrationWorldY}"
Binding="{Binding WorldY}" FontFamily="Segoe UI" Width="*" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
<Grid Grid.Column="1" Background="{StaticResource BackgroundColor}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<controls:ImageCanvasControl Grid.Row="0" x:Name="imageCanvas" Margin="12,12,12,8" />
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
Margin="12,0,12,12" Padding="12" MinHeight="80">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<TextBlock TextWrapping="Wrap" FontSize="12"
Text="{Binding StatusText}" FontFamily="Segoe UI"
Foreground="{StaticResource TextColor}" />
</ScrollViewer>
</Border>
</Grid>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,104 @@
using System.Drawing;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using XP.Camera.Calibration.ViewModels;
using WpfBrushes = System.Windows.Media.Brushes;
using WpfColor = System.Windows.Media.Color;
namespace XP.Camera.Calibration.Controls;
public partial class CalibrationControl : UserControl
{
private CalibrationViewModel? _viewModel;
public CalibrationControl()
{
InitializeComponent();
Loaded += CalibrationControl_Loaded;
}
private void CalibrationControl_Loaded(object sender, RoutedEventArgs e)
{
if (DataContext is CalibrationViewModel viewModel)
{
_viewModel = viewModel;
_viewModel.ImageLoadedRequested += (s, e) =>
{
imageCanvas.ReferenceImage = _viewModel.ImageSource;
imageCanvas.RoiCanvas.Children.Clear();
};
imageCanvas.CanvasRightMouseUp += ImageCanvas_RightMouseUp;
imageCanvas.CanvasMouseWheel += ImageCanvas_MouseWheel;
}
}
private void ImageCanvas_MouseWheel(object? sender, MouseWheelEventArgs e)
{
if (_viewModel?.CurrentImage == null) return;
double zoom = e.Delta > 0 ? 1.1 : 0.9;
imageCanvas.ZoomScale *= zoom;
imageCanvas.ZoomScale = Math.Max(0.1, Math.Min(imageCanvas.ZoomScale, 10));
}
private void ImageCanvas_RightMouseUp(object? sender, MouseButtonEventArgs e)
{
if (_viewModel?.CurrentImage == null) return;
var pos = e.GetPosition(imageCanvas.RoiCanvas);
float imageX = (float)pos.X;
float imageY = (float)pos.Y;
if (imageX >= 0 && imageX < _viewModel.CurrentImage.Width &&
imageY >= 0 && imageY < _viewModel.CurrentImage.Height)
{
var pixelPoint = new PointF(imageX, imageY);
var worldPoint = _viewModel.ConvertPixelToWorld(pixelPoint);
_viewModel.StatusText = $"像素坐标: ({imageX:F2}, {imageY:F2})\n世界坐标: ({worldPoint.X:F2}, {worldPoint.Y:F2})";
DrawMarkerOnCanvas(imageX, imageY, worldPoint);
}
}
private void DrawMarkerOnCanvas(float imageX, float imageY, PointF worldPoint)
{
imageCanvas.RoiCanvas.Children.Clear();
var ellipse = new System.Windows.Shapes.Ellipse
{
Width = 10, Height = 10,
Stroke = WpfBrushes.Red, StrokeThickness = 2,
Fill = WpfBrushes.Transparent
};
Canvas.SetLeft(ellipse, imageX - 5);
Canvas.SetTop(ellipse, imageY - 5);
imageCanvas.RoiCanvas.Children.Add(ellipse);
var pixelText = new TextBlock
{
Text = $"P:({imageX:F0},{imageY:F0})",
Foreground = WpfBrushes.Red, FontSize = 12,
Background = new System.Windows.Media.SolidColorBrush(WpfColor.FromArgb(180, 255, 255, 255))
};
Canvas.SetLeft(pixelText, imageX + 10);
Canvas.SetTop(pixelText, imageY - 20);
imageCanvas.RoiCanvas.Children.Add(pixelText);
if (_viewModel?.ShowWorldCoordinates == true)
{
var worldText = new TextBlock
{
Text = $"W:({worldPoint.X:F2},{worldPoint.Y:F2})",
Foreground = WpfBrushes.Blue, FontSize = 12,
Background = new System.Windows.Media.SolidColorBrush(WpfColor.FromArgb(180, 255, 255, 255))
};
Canvas.SetLeft(worldText, imageX + 10);
Canvas.SetTop(worldText, imageY + 5);
imageCanvas.RoiCanvas.Children.Add(worldText);
}
}
}
@@ -0,0 +1,208 @@
<UserControl x:Class="XP.Camera.Calibration.Controls.ChessboardCalibrationControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:cal="clr-namespace:XP.Camera.Calibration"
xmlns:controls="clr-namespace:XP.Camera.Calibration.Controls"
mc:Ignorable="d"
d:DesignHeight="900"
d:DesignWidth="1600">
<UserControl.Resources>
<cal:CalibrationLocalizedStrings x:Key="LocalizedStrings" />
<SolidColorBrush x:Key="PrimaryColor" Color="#F5F5F5" />
<SolidColorBrush x:Key="AccentColor" Color="#0078D4" />
<SolidColorBrush x:Key="BackgroundColor" Color="#FAFAFA" />
<SolidColorBrush x:Key="SidebarColor" Color="#FFFFFF" />
<SolidColorBrush x:Key="BorderColor" Color="#E1E1E1" />
<SolidColorBrush x:Key="TextColor" Color="#333333" />
<SolidColorBrush x:Key="TextSecondaryColor" Color="#666666" />
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<Style x:Key="ToolbarButtonStyle" TargetType="Button">
<Setter Property="Width" Value="90" />
<Setter Property="Height" Value="70" />
<Setter Property="Margin" Value="0,0,8,0" />
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="{StaticResource TextColor}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter Content="{TemplateBinding Tag}"
HorizontalAlignment="Center"
Margin="0,4,0,4" />
<TextBlock Text="{TemplateBinding Content}"
FontSize="12" FontFamily="Segoe UI"
HorizontalAlignment="Center"
Foreground="{TemplateBinding Foreground}"
TextWrapping="Wrap"
TextAlignment="Center" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#E5F3FF" />
<Setter Property="BorderBrush" Value="{StaticResource AccentColor}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#CCE8FF" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{StaticResource PrimaryColor}" BorderBrush="{StaticResource BorderColor}"
BorderThickness="0,0,0,1" Padding="15,10">
<StackPanel Orientation="Horizontal">
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardAddImages}" Command="{Binding AddImagesCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="ImageMultiple" Width="24" Height="24" /></Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardClearImages}" Command="{Binding ClearImagesCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="DeleteSweep" Width="24" Height="24" /></Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardCalibrate}" Command="{Binding CalibrateCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="GridLarge" Width="24" Height="24" /></Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardSave}" Command="{Binding SaveCalibrationCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="ContentSave" Width="24" Height="24" /></Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardLoad}" Command="{Binding LoadCalibrationCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="FolderOpen" Width="24" Height="24" /></Button.Tag>
</Button>
<Button Content="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardUndistort}" Command="{Binding UndistortImageCommand}"
Style="{StaticResource ToolbarButtonStyle}">
<Button.Tag><iconPacks:PackIconMaterial Kind="ImageEdit" Width="24" Height="24" /></Button.Tag>
</Button>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="400" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" BorderBrush="{StaticResource BorderColor}" BorderThickness="0,0,1,0"
Background="{StaticResource SidebarColor}">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardParameters}" FontFamily="Segoe UI" FontSize="15" FontWeight="SemiBold"
Margin="0,0,0,12" Foreground="{StaticResource TextColor}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardWidth}" FontFamily="Segoe UI" VerticalAlignment="Center" Margin="0,0,8,8" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding ChessboardWidth}" FontFamily="Segoe UI" Height="28" Margin="0,0,0,8" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardHeight}" FontFamily="Segoe UI" VerticalAlignment="Center" Margin="0,0,8,8" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ChessboardHeight}" FontFamily="Segoe UI" Height="28" Margin="0,0,0,8" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardSquareSize}" FontFamily="Segoe UI" VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SquareSize}" FontFamily="Segoe UI" Height="28" />
</Grid>
</StackPanel>
<TextBlock Grid.Row="1" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardImageList}" FontFamily="Segoe UI" FontSize="15" FontWeight="SemiBold"
Margin="0,0,0,12" Foreground="{StaticResource TextColor}" />
<ListBox Grid.Row="2" ItemsSource="{Binding ImageFileNames}" SelectedIndex="{Binding SelectedImageIndex}"
FontFamily="Segoe UI" BorderBrush="{StaticResource BorderColor}" BorderThickness="1" />
</Grid>
</Border>
<Grid Grid.Column="1" Background="{StaticResource BackgroundColor}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="350" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<controls:ImageCanvasControl Grid.Row="0" x:Name="imageCanvas" Margin="12,12,8,8" />
<Border Grid.Row="1" Background="White" BorderBrush="{StaticResource BorderColor}" BorderThickness="1"
Margin="12,0,8,12" Padding="12" Height="70">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding ProgressText}" FontSize="12" FontFamily="Segoe UI"
Margin="0,0,0,6" Foreground="{StaticResource TextColor}"
Visibility="{Binding IsCalibrating, Converter={StaticResource BooleanToVisibilityConverter}}" />
<ProgressBar Grid.Row="1" Height="24" Value="{Binding ProgressValue}" Maximum="100"
Visibility="{Binding IsCalibrating, Converter={StaticResource BooleanToVisibilityConverter}}" />
<TextBlock Grid.Row="0" Grid.RowSpan="2" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardStatusReady}" FontFamily="Segoe UI" FontSize="12"
VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryColor}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsCalibrating}" Value="False">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border>
</Grid>
<Border Grid.Column="1" Background="White" BorderBrush="{StaticResource BorderColor}"
BorderThickness="1,0,0,0" Padding="12" Margin="0,12,12,12">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding Source={StaticResource LocalizedStrings}, Path=Resources.ChessboardStatusInfo}" FontFamily="Segoe UI" FontSize="15" FontWeight="SemiBold"
Margin="0,0,0,12" Foreground="{StaticResource TextColor}" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<TextBlock TextWrapping="Wrap" FontSize="12" FontFamily="Segoe UI"
Text="{Binding StatusText}" Foreground="{StaticResource TextColor}" />
</ScrollViewer>
</Grid>
</Border>
</Grid>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,46 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using XP.Camera.Calibration.ViewModels;
namespace XP.Camera.Calibration.Controls;
public partial class ChessboardCalibrationControl : UserControl
{
private ChessboardCalibrationViewModel? _viewModel;
public ChessboardCalibrationControl()
{
InitializeComponent();
Loaded += ChessboardCalibrationControl_Loaded;
}
private void ChessboardCalibrationControl_Loaded(object sender, RoutedEventArgs e)
{
if (DataContext is ChessboardCalibrationViewModel viewModel)
{
_viewModel = viewModel;
_viewModel.ImageLoadedRequested += (s, e) =>
{
imageCanvas.ReferenceImage = _viewModel.ImageSource;
};
_viewModel.ImageClearedRequested += (s, e) =>
{
imageCanvas.ReferenceImage = null;
};
imageCanvas.CanvasMouseWheel += ImageCanvas_MouseWheel;
}
}
private void ImageCanvas_MouseWheel(object? sender, MouseWheelEventArgs e)
{
if (_viewModel?.ImageSource == null) return;
double zoom = e.Delta > 0 ? 1.1 : 0.9;
imageCanvas.ZoomScale *= zoom;
imageCanvas.ZoomScale = Math.Max(0.1, Math.Min(imageCanvas.ZoomScale, 10));
}
}
@@ -0,0 +1,33 @@
<UserControl x:Class="XP.Camera.Calibration.Controls.ImageCanvasControl"
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"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="800" x:Name="imageCanvasControl">
<Border ClipToBounds="True" RenderOptions.BitmapScalingMode="NearestNeighbor">
<Viewbox>
<AdornerDecorator x:Name="adorner" MouseWheel="Adorner_MouseWheel">
<AdornerDecorator.RenderTransform>
<TransformGroup>
<TranslateTransform X="{Binding PanningOffsetX, ElementName=imageCanvasControl}"
Y="{Binding PanningOffsetY, ElementName=imageCanvasControl}" />
<ScaleTransform ScaleX="{Binding ZoomScale, ElementName=imageCanvasControl}"
ScaleY="{Binding ZoomScale, ElementName=imageCanvasControl}"
CenterX="{Binding ZoomCenter.X, ElementName=imageCanvasControl}"
CenterY="{Binding ZoomCenter.Y, ElementName=imageCanvasControl}" />
</TransformGroup>
</AdornerDecorator.RenderTransform>
<Grid PreviewMouseMove="Canvas_MouseMove"
PreviewMouseLeftButtonUp="Canvas_MouseLeftButtonUp"
PreviewMouseRightButtonUp="Canvas_MouseRightButtonUp"
MouseEnter="Canvas_MouseEnter"
PreviewMouseLeftButtonDown="Canvas_MouseLeftButtonDown"
PreviewMouseRightButtonDown="Canvas_MouseRightButtonDown">
<ContentPresenter Content="{Binding RoiCanvas, ElementName=imageCanvasControl}"
SizeChanged="ContentPresenter_SizeChanged" />
</Grid>
</AdornerDecorator>
</Viewbox>
</Border>
</UserControl>
@@ -0,0 +1,229 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace XP.Camera.Calibration.Controls;
/// <summary>
/// 图像画布控件 - 提供图像显示、缩放、平移功能
/// </summary>
public partial class ImageCanvasControl : UserControl
{
private Point mouseDownPoint = new Point();
#region Dependency Properties
public static readonly DependencyProperty ZoomScaleProperty =
DependencyProperty.Register("ZoomScale", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(1.0));
public static readonly DependencyProperty ZoomCenterProperty =
DependencyProperty.Register("ZoomCenter", typeof(Point), typeof(ImageCanvasControl), new PropertyMetadata(new Point()));
public static readonly DependencyProperty PanningOffsetXProperty =
DependencyProperty.Register("PanningOffsetX", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(0.0));
public static readonly DependencyProperty PanningOffsetYProperty =
DependencyProperty.Register("PanningOffsetY", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(0.0));
public static readonly DependencyProperty ReferenceImageProperty =
DependencyProperty.Register("ReferenceImage", typeof(BitmapSource), typeof(ImageCanvasControl),
new UIPropertyMetadata(null, ReferenceImageChanged));
public static readonly DependencyProperty ImageScaleFactorProperty =
DependencyProperty.Register("ImageScaleFactor", typeof(double), typeof(ImageCanvasControl), new PropertyMetadata(1.0));
public static readonly DependencyProperty MaxImageWidthProperty =
DependencyProperty.Register("MaxImageWidth", typeof(int), typeof(ImageCanvasControl), new PropertyMetadata(0));
public static readonly DependencyProperty MaxImageHeightProperty =
DependencyProperty.Register("MaxImageHeight", typeof(int), typeof(ImageCanvasControl), new PropertyMetadata(0));
public static readonly DependencyProperty EnablePanningProperty =
DependencyProperty.Register("EnablePanning", typeof(bool), typeof(ImageCanvasControl), new PropertyMetadata(true));
#endregion
#region Properties
public double ZoomScale
{
get => (double)GetValue(ZoomScaleProperty);
set => SetValue(ZoomScaleProperty, value);
}
public Point ZoomCenter
{
get => (Point)GetValue(ZoomCenterProperty);
set => SetValue(ZoomCenterProperty, value);
}
public double PanningOffsetX
{
get => (double)GetValue(PanningOffsetXProperty);
set => SetValue(PanningOffsetXProperty, value);
}
public double PanningOffsetY
{
get => (double)GetValue(PanningOffsetYProperty);
set => SetValue(PanningOffsetYProperty, value);
}
public BitmapSource? ReferenceImage
{
get => (BitmapSource?)GetValue(ReferenceImageProperty);
set => SetValue(ReferenceImageProperty, value);
}
public double ImageScaleFactor
{
get => (double)GetValue(ImageScaleFactorProperty);
set => SetValue(ImageScaleFactorProperty, value);
}
public int MaxImageWidth
{
get => (int)GetValue(MaxImageWidthProperty);
set => SetValue(MaxImageWidthProperty, value);
}
public int MaxImageHeight
{
get => (int)GetValue(MaxImageHeightProperty);
set => SetValue(MaxImageHeightProperty, value);
}
public bool EnablePanning
{
get => (bool)GetValue(EnablePanningProperty);
set => SetValue(EnablePanningProperty, value);
}
private Canvas roiCanvas = new Canvas();
public Canvas RoiCanvas
{
get => roiCanvas;
set => roiCanvas = value;
}
#endregion
#region Events
public event EventHandler<MouseButtonEventArgs>? CanvasRightMouseUp;
public event EventHandler<MouseButtonEventArgs>? CanvasRightMouseDown;
public event EventHandler<MouseButtonEventArgs>? CanvasLeftMouseDown;
public event EventHandler<MouseEventArgs>? CanvasMouseMove;
public event EventHandler<MouseWheelEventArgs>? CanvasMouseWheel;
#endregion
public ImageCanvasControl()
{
InitializeComponent();
}
#region Private Methods
private static void ReferenceImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
(d as ImageCanvasControl)?.OnReferenceImageChanged(e.NewValue as BitmapSource);
}
private void OnReferenceImageChanged(BitmapSource? bitmapSource)
{
if (bitmapSource != null)
{
ImageBrush brush = new ImageBrush { ImageSource = bitmapSource, Stretch = Stretch.Uniform };
RoiCanvas.Background = brush;
RoiCanvas.Height = bitmapSource.Height;
RoiCanvas.Width = bitmapSource.Width;
}
else
{
RoiCanvas.Height = MaxImageHeight > 0 ? MaxImageHeight : 600;
RoiCanvas.Width = MaxImageWidth > 0 ? MaxImageWidth : 800;
RoiCanvas.Background = Brushes.LightGray;
}
}
private double CalculateScaleFactor()
{
if (ActualWidth <= 0) return 1;
double scaleFactor = Math.Max(RoiCanvas.Width / ActualWidth, RoiCanvas.Height / ActualHeight);
if (scaleFactor < 0)
scaleFactor = Math.Min(RoiCanvas.Width / ActualWidth, RoiCanvas.Height / ActualHeight);
return scaleFactor;
}
#endregion
#region Event Handlers
private void Canvas_MouseEnter(object sender, MouseEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
mouseDownPoint = e.GetPosition(RoiCanvas);
}
private void Canvas_MouseMove(object sender, MouseEventArgs e)
{
CanvasMouseMove?.Invoke(sender, e);
if (EnablePanning && e.LeftButton == MouseButtonState.Pressed)
{
Point mousePoint = e.GetPosition(RoiCanvas);
double mouseMoveLength = Point.Subtract(mousePoint, mouseDownPoint).Length;
if (mouseMoveLength > (10 * CalculateScaleFactor()) / ZoomScale)
{
PanningOffsetX += mousePoint.X - mouseDownPoint.X;
PanningOffsetY += mousePoint.Y - mouseDownPoint.Y;
}
}
}
private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { }
private void Canvas_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
CanvasRightMouseUp?.Invoke(sender, e);
}
private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
mouseDownPoint = e.GetPosition(RoiCanvas);
CanvasLeftMouseDown?.Invoke(sender, e);
if (EnablePanning && e.ClickCount == 2)
{
ResetView();
e.Handled = true;
}
}
private void Canvas_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
CanvasRightMouseDown?.Invoke(sender, e);
e.Handled = true;
}
private void Adorner_MouseWheel(object sender, MouseWheelEventArgs e)
{
CanvasMouseWheel?.Invoke(sender, e);
}
private void ContentPresenter_SizeChanged(object sender, SizeChangedEventArgs e)
{
ImageScaleFactor = CalculateScaleFactor();
}
private void ResetView()
{
ZoomScale = 1.0;
PanningOffsetX = 0.0;
PanningOffsetY = 0.0;
ZoomCenter = new Point(0, 0);
}
#endregion
}
@@ -0,0 +1,50 @@
using Microsoft.Win32;
using System.Windows;
using XP.Common.GeneralForm.Views;
namespace XP.Camera.Calibration;
/// <summary>
/// 默认对话框服务实现,使用 HexMessageBox 自定义消息框。
/// 外部项目可直接使用,无需额外依赖。
/// </summary>
public class DefaultCalibrationDialogService : ICalibrationDialogService
{
public void ShowMessage(string message, string title)
{
HexMessageBox.Show(message, MessageBoxButton.OK, MessageBoxImage.Information);
}
public void ShowError(string message, string title)
{
HexMessageBox.Show(message, MessageBoxButton.OK, MessageBoxImage.Error);
}
public void ShowInfo(string message, string title)
{
HexMessageBox.Show(message, MessageBoxButton.OK, MessageBoxImage.Information);
}
public bool ShowConfirm(string message, string title)
{
return HexMessageBox.Show(message, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes;
}
public string? ShowOpenFileDialog(string filter)
{
var dialog = new OpenFileDialog { Filter = filter };
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
public string[]? ShowOpenMultipleFilesDialog(string filter)
{
var dialog = new OpenFileDialog { Filter = filter, Multiselect = true };
return dialog.ShowDialog() == true ? dialog.FileNames : null;
}
public string? ShowSaveFileDialog(string filter, string? defaultFileName = null)
{
var dialog = new SaveFileDialog { Filter = filter, FileName = defaultFileName ?? string.Empty };
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace XP.Camera.Calibration;
/// <summary>
/// 对话框服务接口,用于标定模块的文件选择和消息提示。
/// </summary>
public interface ICalibrationDialogService
{
void ShowMessage(string message, string title);
void ShowError(string message, string title);
void ShowInfo(string message, string title);
bool ShowConfirm(string message, string title);
string? ShowOpenFileDialog(string filter);
string[]? ShowOpenMultipleFilesDialog(string filter);
string? ShowSaveFileDialog(string filter, string? defaultFileName = null);
}
@@ -0,0 +1,104 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 此代码由工具生成。
// 如果重新生成代码,将丢失对此文件所做的更改。
// </auto-generated>
//------------------------------------------------------------------------------
namespace XP.Camera.Calibration.Resources {
using System;
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class CalibrationResources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal CalibrationResources() { }
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("XP.Camera.Calibration.Resources.CalibrationResources", typeof(CalibrationResources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get { return resourceCulture; }
set { resourceCulture = value; }
}
// 九点标定
public static string CalibrationToolTitle => ResourceManager.GetString("CalibrationToolTitle", resourceCulture) ?? "";
public static string CalibrationLoadImage => ResourceManager.GetString("CalibrationLoadImage", resourceCulture) ?? "";
public static string CalibrationLoadCsv => ResourceManager.GetString("CalibrationLoadCsv", resourceCulture) ?? "";
public static string CalibrationExecute => ResourceManager.GetString("CalibrationExecute", resourceCulture) ?? "";
public static string CalibrationSave => ResourceManager.GetString("CalibrationSave", resourceCulture) ?? "";
public static string CalibrationLoad => ResourceManager.GetString("CalibrationLoad", resourceCulture) ?? "";
public static string CalibrationShowWorld => ResourceManager.GetString("CalibrationShowWorld", resourceCulture) ?? "";
public static string CalibrationPointList => ResourceManager.GetString("CalibrationPointList", resourceCulture) ?? "";
public static string CalibrationPixelX => ResourceManager.GetString("CalibrationPixelX", resourceCulture) ?? "";
public static string CalibrationPixelY => ResourceManager.GetString("CalibrationPixelY", resourceCulture) ?? "";
public static string CalibrationWorldX => ResourceManager.GetString("CalibrationWorldX", resourceCulture) ?? "";
public static string CalibrationWorldY => ResourceManager.GetString("CalibrationWorldY", resourceCulture) ?? "";
public static string CalibrationStatusReady => ResourceManager.GetString("CalibrationStatusReady", resourceCulture) ?? "";
public static string CalibrationStatusImageLoaded => ResourceManager.GetString("CalibrationStatusImageLoaded", resourceCulture) ?? "";
public static string CalibrationStatusCsvLoaded => ResourceManager.GetString("CalibrationStatusCsvLoaded", resourceCulture) ?? "";
public static string CalibrationStatusSuccess => ResourceManager.GetString("CalibrationStatusSuccess", resourceCulture) ?? "";
public static string CalibrationStatusFailed => ResourceManager.GetString("CalibrationStatusFailed", resourceCulture) ?? "";
public static string CalibrationStatusSaved => ResourceManager.GetString("CalibrationStatusSaved", resourceCulture) ?? "";
public static string CalibrationStatusLoaded => ResourceManager.GetString("CalibrationStatusLoaded", resourceCulture) ?? "";
public static string CalibrationCoordinates => ResourceManager.GetString("CalibrationCoordinates", resourceCulture) ?? "";
public static string CalibrationErrorMinPoints => ResourceManager.GetString("CalibrationErrorMinPoints", resourceCulture) ?? "";
public static string CalibrationSuccessTitle => ResourceManager.GetString("CalibrationSuccessTitle", resourceCulture) ?? "";
public static string CalibrationSuccessMessage => ResourceManager.GetString("CalibrationSuccessMessage", resourceCulture) ?? "";
public static string CalibrationSaveSuccess => ResourceManager.GetString("CalibrationSaveSuccess", resourceCulture) ?? "";
public static string CalibrationLoadSuccess => ResourceManager.GetString("CalibrationLoadSuccess", resourceCulture) ?? "";
public static string CalibrationLoadFailed => ResourceManager.GetString("CalibrationLoadFailed", resourceCulture) ?? "";
// 棋盘格标定
public static string ChessboardToolTitle => ResourceManager.GetString("ChessboardToolTitle", resourceCulture) ?? "";
public static string ChessboardAddImages => ResourceManager.GetString("ChessboardAddImages", resourceCulture) ?? "";
public static string ChessboardClearImages => ResourceManager.GetString("ChessboardClearImages", resourceCulture) ?? "";
public static string ChessboardCalibrate => ResourceManager.GetString("ChessboardCalibrate", resourceCulture) ?? "";
public static string ChessboardSave => ResourceManager.GetString("ChessboardSave", resourceCulture) ?? "";
public static string ChessboardLoad => ResourceManager.GetString("ChessboardLoad", resourceCulture) ?? "";
public static string ChessboardUndistort => ResourceManager.GetString("ChessboardUndistort", resourceCulture) ?? "";
public static string ChessboardParameters => ResourceManager.GetString("ChessboardParameters", resourceCulture) ?? "";
public static string ChessboardWidth => ResourceManager.GetString("ChessboardWidth", resourceCulture) ?? "";
public static string ChessboardHeight => ResourceManager.GetString("ChessboardHeight", resourceCulture) ?? "";
public static string ChessboardSquareSize => ResourceManager.GetString("ChessboardSquareSize", resourceCulture) ?? "";
public static string ChessboardImageList => ResourceManager.GetString("ChessboardImageList", resourceCulture) ?? "";
public static string ChessboardStatusInfo => ResourceManager.GetString("ChessboardStatusInfo", resourceCulture) ?? "";
public static string ChessboardStatusReady => ResourceManager.GetString("ChessboardStatusReady", resourceCulture) ?? "";
public static string ChessboardStatusAdded => ResourceManager.GetString("ChessboardStatusAdded", resourceCulture) ?? "";
public static string ChessboardStatusCleared => ResourceManager.GetString("ChessboardStatusCleared", resourceCulture) ?? "";
public static string ChessboardStatusCalibrating => ResourceManager.GetString("ChessboardStatusCalibrating", resourceCulture) ?? "";
public static string ChessboardStatusSuccess => ResourceManager.GetString("ChessboardStatusSuccess", resourceCulture) ?? "";
public static string ChessboardStatusFailed => ResourceManager.GetString("ChessboardStatusFailed", resourceCulture) ?? "";
public static string ChessboardStatusSaved => ResourceManager.GetString("ChessboardStatusSaved", resourceCulture) ?? "";
public static string ChessboardStatusLoaded => ResourceManager.GetString("ChessboardStatusLoaded", resourceCulture) ?? "";
public static string ChessboardStatusUndistorted => ResourceManager.GetString("ChessboardStatusUndistorted", resourceCulture) ?? "";
public static string ChessboardStatusImageError => ResourceManager.GetString("ChessboardStatusImageError", resourceCulture) ?? "";
public static string ChessboardProgressPreparing => ResourceManager.GetString("ChessboardProgressPreparing", resourceCulture) ?? "";
public static string ChessboardProgressDetecting => ResourceManager.GetString("ChessboardProgressDetecting", resourceCulture) ?? "";
public static string ChessboardProgressCalibrating => ResourceManager.GetString("ChessboardProgressCalibrating", resourceCulture) ?? "";
public static string ChessboardProgressCalculating => ResourceManager.GetString("ChessboardProgressCalculating", resourceCulture) ?? "";
public static string ChessboardProgressComplete => ResourceManager.GetString("ChessboardProgressComplete", resourceCulture) ?? "";
public static string ChessboardProgressFailed => ResourceManager.GetString("ChessboardProgressFailed", resourceCulture) ?? "";
public static string ChessboardErrorMinImages => ResourceManager.GetString("ChessboardErrorMinImages", resourceCulture) ?? "";
public static string ChessboardErrorInsufficientValid => ResourceManager.GetString("ChessboardErrorInsufficientValid", resourceCulture) ?? "";
public static string ChessboardSaveSuccess => ResourceManager.GetString("ChessboardSaveSuccess", resourceCulture) ?? "";
public static string ChessboardLoadSuccess => ResourceManager.GetString("ChessboardLoadSuccess", resourceCulture) ?? "";
public static string ChessboardCalibrationComplete => ResourceManager.GetString("ChessboardCalibrationComplete", resourceCulture) ?? "";
public static string ChessboardImageError => ResourceManager.GetString("ChessboardImageError", resourceCulture) ?? "";
}
}
@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- Nine-Point Calibration -->
<data name="CalibrationToolTitle" xml:space="preserve">
<value>Nine-Point Calibration Tool</value>
</data>
<data name="CalibrationLoadImage" xml:space="preserve">
<value>Load Image</value>
</data>
<data name="CalibrationLoadCsv" xml:space="preserve">
<value>Load from CSV</value>
</data>
<data name="CalibrationExecute" xml:space="preserve">
<value>Calibrate</value>
</data>
<data name="CalibrationSave" xml:space="preserve">
<value>Save Calibration</value>
</data>
<data name="CalibrationLoad" xml:space="preserve">
<value>Load Calibration</value>
</data>
<data name="CalibrationShowWorld" xml:space="preserve">
<value>Show World Coordinates</value>
</data>
<data name="CalibrationPointList" xml:space="preserve">
<value>Calibration Points</value>
</data>
<data name="CalibrationPixelX" xml:space="preserve">
<value>Pixel X</value>
</data>
<data name="CalibrationPixelY" xml:space="preserve">
<value>Pixel Y</value>
</data>
<data name="CalibrationWorldX" xml:space="preserve">
<value>World X</value>
</data>
<data name="CalibrationWorldY" xml:space="preserve">
<value>World Y</value>
</data>
<data name="CalibrationStatusReady" xml:space="preserve">
<value>Ready</value>
</data>
<data name="CalibrationStatusImageLoaded" xml:space="preserve">
<value>Status: Image loaded
{0}
Right-click on image to view coordinate conversion</value>
</data>
<data name="CalibrationStatusCsvLoaded" xml:space="preserve">
<value>Status: Loaded {0} calibration points from CSV
{1}</value>
</data>
<data name="CalibrationStatusSuccess" xml:space="preserve">
<value>Status: Calibration successful! Using {0} points</value>
</data>
<data name="CalibrationStatusFailed" xml:space="preserve">
<value>Status: Calibration failed</value>
</data>
<data name="CalibrationStatusSaved" xml:space="preserve">
<value>Status: Calibration saved to
{0}</value>
</data>
<data name="CalibrationStatusLoaded" xml:space="preserve">
<value>Status: Calibration loaded from
{0}</value>
</data>
<data name="CalibrationCoordinates" xml:space="preserve">
<value>Pixel coordinates: ({0:F2}, {1:F2})
World coordinates: ({2:F2}, {3:F2})</value>
</data>
<data name="CalibrationErrorMinPoints" xml:space="preserve">
<value>At least 4 calibration points required!</value>
</data>
<data name="CalibrationSuccessTitle" xml:space="preserve">
<value>Success</value>
</data>
<data name="CalibrationSuccessMessage" xml:space="preserve">
<value>Calibration completed!</value>
</data>
<data name="CalibrationSaveSuccess" xml:space="preserve">
<value>Save successful!</value>
</data>
<data name="CalibrationLoadSuccess" xml:space="preserve">
<value>Load successful!</value>
</data>
<data name="CalibrationLoadFailed" xml:space="preserve">
<value>Load failed!</value>
</data>
<!-- Chessboard Calibration -->
<data name="ChessboardToolTitle" xml:space="preserve">
<value>Chessboard Calibration Tool</value>
</data>
<data name="ChessboardAddImages" xml:space="preserve">
<value>Add Images</value>
</data>
<data name="ChessboardClearImages" xml:space="preserve">
<value>Clear List</value>
</data>
<data name="ChessboardCalibrate" xml:space="preserve">
<value>Calibrate</value>
</data>
<data name="ChessboardSave" xml:space="preserve">
<value>Save Calibration</value>
</data>
<data name="ChessboardLoad" xml:space="preserve">
<value>Load Calibration</value>
</data>
<data name="ChessboardUndistort" xml:space="preserve">
<value>Undistort Image</value>
</data>
<data name="ChessboardParameters" xml:space="preserve">
<value>Chessboard Parameters</value>
</data>
<data name="ChessboardWidth" xml:space="preserve">
<value>Inner Corners Width:</value>
</data>
<data name="ChessboardHeight" xml:space="preserve">
<value>Inner Corners Height:</value>
</data>
<data name="ChessboardSquareSize" xml:space="preserve">
<value>Square Size (mm):</value>
</data>
<data name="ChessboardImageList" xml:space="preserve">
<value>Calibration Images</value>
</data>
<data name="ChessboardStatusInfo" xml:space="preserve">
<value>Status Information</value>
</data>
<data name="ChessboardStatusReady" xml:space="preserve">
<value>Ready</value>
</data>
<data name="ChessboardStatusAdded" xml:space="preserve">
<value>Added {0} images</value>
</data>
<data name="ChessboardStatusCleared" xml:space="preserve">
<value>Image list cleared</value>
</data>
<data name="ChessboardStatusCalibrating" xml:space="preserve">
<value>Calibrating, please wait...</value>
</data>
<data name="ChessboardStatusSuccess" xml:space="preserve">
<value>Calibration successful!
Overall reprojection error: {0:F4} pixels
{1}</value>
</data>
<data name="ChessboardStatusFailed" xml:space="preserve">
<value>Calibration failed: {0}</value>
</data>
<data name="ChessboardStatusSaved" xml:space="preserve">
<value>Calibration saved:
{0}</value>
</data>
<data name="ChessboardStatusLoaded" xml:space="preserve">
<value>Calibration loaded:
{0}</value>
</data>
<data name="ChessboardStatusUndistorted" xml:space="preserve">
<value>Image undistorted:
{0}</value>
</data>
<data name="ChessboardStatusImageError" xml:space="preserve">
<value>Image {0}
Reprojection error: {1:F4} pixels</value>
</data>
<data name="ChessboardProgressPreparing" xml:space="preserve">
<value>Preparing calibration...</value>
</data>
<data name="ChessboardProgressDetecting" xml:space="preserve">
<value>Detecting corners ({0}/{1})</value>
</data>
<data name="ChessboardProgressCalibrating" xml:space="preserve">
<value>Performing camera calibration...</value>
</data>
<data name="ChessboardProgressCalculating" xml:space="preserve">
<value>Calculating reprojection errors ({0}/{1})</value>
</data>
<data name="ChessboardProgressComplete" xml:space="preserve">
<value>Calibration complete</value>
</data>
<data name="ChessboardProgressFailed" xml:space="preserve">
<value>Calibration failed</value>
</data>
<data name="ChessboardErrorMinImages" xml:space="preserve">
<value>At least 3 images required!</value>
</data>
<data name="ChessboardErrorInsufficientValid" xml:space="preserve">
<value>Insufficient valid images, need at least 3, current {0}</value>
</data>
<data name="ChessboardSaveSuccess" xml:space="preserve">
<value>Save successful!</value>
</data>
<data name="ChessboardLoadSuccess" xml:space="preserve">
<value>Load successful!</value>
</data>
<data name="ChessboardCalibrationComplete" xml:space="preserve">
<value>Calibration completed!</value>
</data>
<data name="ChessboardImageError" xml:space="preserve">
<value>Image{0}: {1:F4} pixels</value>
</data>
</root>
@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<!-- 九点标定 -->
<data name="CalibrationToolTitle" xml:space="preserve">
<value>九点标定工具</value>
</data>
<data name="CalibrationLoadImage" xml:space="preserve">
<value>加载图像</value>
</data>
<data name="CalibrationLoadCsv" xml:space="preserve">
<value>从CSV加载</value>
</data>
<data name="CalibrationExecute" xml:space="preserve">
<value>执行标定</value>
</data>
<data name="CalibrationSave" xml:space="preserve">
<value>保存标定</value>
</data>
<data name="CalibrationLoad" xml:space="preserve">
<value>加载标定</value>
</data>
<data name="CalibrationShowWorld" xml:space="preserve">
<value>显示世界坐标</value>
</data>
<data name="CalibrationPointList" xml:space="preserve">
<value>标定点列表</value>
</data>
<data name="CalibrationPixelX" xml:space="preserve">
<value>像素X</value>
</data>
<data name="CalibrationPixelY" xml:space="preserve">
<value>像素Y</value>
</data>
<data name="CalibrationWorldX" xml:space="preserve">
<value>世界X</value>
</data>
<data name="CalibrationWorldY" xml:space="preserve">
<value>世界Y</value>
</data>
<data name="CalibrationStatusReady" xml:space="preserve">
<value>就绪</value>
</data>
<data name="CalibrationStatusImageLoaded" xml:space="preserve">
<value>状态:图像已加载
{0}
右键点击图像查看坐标转换</value>
</data>
<data name="CalibrationStatusCsvLoaded" xml:space="preserve">
<value>状态:已从CSV加载 {0} 个标定点
{1}</value>
</data>
<data name="CalibrationStatusSuccess" xml:space="preserve">
<value>状态:标定成功!使用 {0} 个点</value>
</data>
<data name="CalibrationStatusFailed" xml:space="preserve">
<value>状态:标定失败</value>
</data>
<data name="CalibrationStatusSaved" xml:space="preserve">
<value>状态:标定文件已保存到
{0}</value>
</data>
<data name="CalibrationStatusLoaded" xml:space="preserve">
<value>状态:标定文件已加载
{0}</value>
</data>
<data name="CalibrationCoordinates" xml:space="preserve">
<value>像素坐标: ({0:F2}, {1:F2})
世界坐标: ({2:F2}, {3:F2})</value>
</data>
<data name="CalibrationErrorMinPoints" xml:space="preserve">
<value>至少需要4个标定点!</value>
</data>
<data name="CalibrationSuccessTitle" xml:space="preserve">
<value>成功</value>
</data>
<data name="CalibrationSuccessMessage" xml:space="preserve">
<value>标定完成!</value>
</data>
<data name="CalibrationSaveSuccess" xml:space="preserve">
<value>保存成功!</value>
</data>
<data name="CalibrationLoadSuccess" xml:space="preserve">
<value>加载成功!</value>
</data>
<data name="CalibrationLoadFailed" xml:space="preserve">
<value>加载失败!</value>
</data>
<!-- 棋盘格标定 -->
<data name="ChessboardToolTitle" xml:space="preserve">
<value>棋盘格畸变校正工具</value>
</data>
<data name="ChessboardAddImages" xml:space="preserve">
<value>添加图像</value>
</data>
<data name="ChessboardClearImages" xml:space="preserve">
<value>清空列表</value>
</data>
<data name="ChessboardCalibrate" xml:space="preserve">
<value>执行标定</value>
</data>
<data name="ChessboardSave" xml:space="preserve">
<value>保存标定</value>
</data>
<data name="ChessboardLoad" xml:space="preserve">
<value>加载标定</value>
</data>
<data name="ChessboardUndistort" xml:space="preserve">
<value>校正图像</value>
</data>
<data name="ChessboardParameters" xml:space="preserve">
<value>棋盘格参数</value>
</data>
<data name="ChessboardWidth" xml:space="preserve">
<value>内角点宽度:</value>
</data>
<data name="ChessboardHeight" xml:space="preserve">
<value>内角点高度:</value>
</data>
<data name="ChessboardSquareSize" xml:space="preserve">
<value>方格尺寸(mm):</value>
</data>
<data name="ChessboardImageList" xml:space="preserve">
<value>标定图像列表</value>
</data>
<data name="ChessboardStatusInfo" xml:space="preserve">
<value>状态信息</value>
</data>
<data name="ChessboardStatusReady" xml:space="preserve">
<value>就绪</value>
</data>
<data name="ChessboardStatusAdded" xml:space="preserve">
<value>已添加 {0} 张图像</value>
</data>
<data name="ChessboardStatusCleared" xml:space="preserve">
<value>已清空图像列表</value>
</data>
<data name="ChessboardStatusCalibrating" xml:space="preserve">
<value>正在标定,请稍候...</value>
</data>
<data name="ChessboardStatusSuccess" xml:space="preserve">
<value>标定成功!
总体重投影误差: {0:F4} 像素
{1}</value>
</data>
<data name="ChessboardStatusFailed" xml:space="preserve">
<value>标定失败: {0}</value>
</data>
<data name="ChessboardStatusSaved" xml:space="preserve">
<value>标定已保存:
{0}</value>
</data>
<data name="ChessboardStatusLoaded" xml:space="preserve">
<value>标定已加载:
{0}</value>
</data>
<data name="ChessboardStatusUndistorted" xml:space="preserve">
<value>已校正图像:
{0}</value>
</data>
<data name="ChessboardStatusImageError" xml:space="preserve">
<value>图像 {0}
重投影误差: {1:F4} 像素</value>
</data>
<data name="ChessboardProgressPreparing" xml:space="preserve">
<value>准备标定...</value>
</data>
<data name="ChessboardProgressDetecting" xml:space="preserve">
<value>检测角点 ({0}/{1})</value>
</data>
<data name="ChessboardProgressCalibrating" xml:space="preserve">
<value>执行相机标定...</value>
</data>
<data name="ChessboardProgressCalculating" xml:space="preserve">
<value>计算重投影误差 ({0}/{1})</value>
</data>
<data name="ChessboardProgressComplete" xml:space="preserve">
<value>标定完成</value>
</data>
<data name="ChessboardProgressFailed" xml:space="preserve">
<value>标定失败</value>
</data>
<data name="ChessboardErrorMinImages" xml:space="preserve">
<value>至少需要3张图像!</value>
</data>
<data name="ChessboardErrorInsufficientValid" xml:space="preserve">
<value>有效图像不足,需要至少3张,当前{0}张</value>
</data>
<data name="ChessboardSaveSuccess" xml:space="preserve">
<value>保存成功!</value>
</data>
<data name="ChessboardLoadSuccess" xml:space="preserve">
<value>加载成功!</value>
</data>
<data name="ChessboardCalibrationComplete" xml:space="preserve">
<value>标定完成!</value>
</data>
<data name="ChessboardImageError" xml:space="preserve">
<value>图像{0}: {1:F4} 像素</value>
</data>
</root>
@@ -0,0 +1,158 @@
using Emgu.CV;
using Emgu.CV.Structure;
using Serilog;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Windows.Media.Imaging;
using Res = XP.Camera.Calibration.Resources.CalibrationResources;
namespace XP.Camera.Calibration.ViewModels;
public class CalibrationViewModel : BindableBase
{
private readonly ICalibrationDialogService _dialogService;
private readonly CalibrationProcessor _calibrator = new();
private Image<Bgr, byte>? _currentImage;
private static readonly ILogger _logger = Log.ForContext<CalibrationViewModel>();
private BitmapSource? _imageSource;
private string _statusText = Res.CalibrationStatusReady;
private bool _showWorldCoordinates;
public CalibrationViewModel(ICalibrationDialogService dialogService)
{
_dialogService = dialogService;
CalibrationPoints = new ObservableCollection<CalibrationProcessor.CalibrationPoint>();
LoadImageCommand = new DelegateCommand(LoadImage);
LoadCsvCommand = new DelegateCommand(LoadCsv);
CalibrateCommand = new DelegateCommand(Calibrate, CanCalibrate)
.ObservesProperty(() => CalibrationPoints.Count);
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
LoadCalibrationCommand = new DelegateCommand(LoadCalibration);
}
public ObservableCollection<CalibrationProcessor.CalibrationPoint> CalibrationPoints { get; }
public BitmapSource? ImageSource
{
get => _imageSource;
set => SetProperty(ref _imageSource, value);
}
public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
public bool ShowWorldCoordinates
{
get => _showWorldCoordinates;
set => SetProperty(ref _showWorldCoordinates, value);
}
public DelegateCommand LoadImageCommand { get; }
public DelegateCommand LoadCsvCommand { get; }
public DelegateCommand CalibrateCommand { get; }
public DelegateCommand SaveCalibrationCommand { get; }
public DelegateCommand LoadCalibrationCommand { get; }
private void LoadImage()
{
_logger.Information("Loading image file");
var fileName = _dialogService.ShowOpenFileDialog("图像文件|*.jpg;*.png;*.bmp;*.tif");
if (fileName == null) return;
_currentImage = new Image<Bgr, byte>(fileName);
ImageSource = MatToBitmapSource(_currentImage.Mat);
StatusText = string.Format(Res.CalibrationStatusImageLoaded, fileName);
RaiseEvent(ImageLoadedRequested);
}
private void LoadCsv()
{
var fileName = _dialogService.ShowOpenFileDialog("CSV文件|*.csv|所有文件|*.*");
if (fileName == null) return;
var points = _calibrator.LoadPointsFromCsv(fileName);
CalibrationPoints.Clear();
foreach (var pt in points)
CalibrationPoints.Add(pt);
StatusText = string.Format(Res.CalibrationStatusCsvLoaded, CalibrationPoints.Count, fileName);
}
private bool CanCalibrate() => CalibrationPoints.Count >= 4;
private void Calibrate()
{
if (CalibrationPoints.Count < 4)
{
_dialogService.ShowError(Res.CalibrationErrorMinPoints, Res.CalibrationSuccessTitle);
return;
}
if (_calibrator.Calibrate(new List<CalibrationProcessor.CalibrationPoint>(CalibrationPoints)))
{
StatusText = string.Format(Res.CalibrationStatusSuccess, CalibrationPoints.Count);
_dialogService.ShowInfo(Res.CalibrationSuccessMessage, Res.CalibrationSuccessTitle);
}
else
{
StatusText = Res.CalibrationStatusFailed;
_dialogService.ShowError(Res.CalibrationStatusFailed, Res.CalibrationSuccessTitle);
}
}
private void SaveCalibration()
{
var fileName = _dialogService.ShowSaveFileDialog("标定文件|*.json", "calibration.json");
if (fileName == null) return;
_calibrator.SaveCalibration(fileName, new List<CalibrationProcessor.CalibrationPoint>(CalibrationPoints));
StatusText = string.Format(Res.CalibrationStatusSaved, fileName);
_dialogService.ShowInfo(Res.CalibrationSaveSuccess, Res.CalibrationSuccessTitle);
}
private void LoadCalibration()
{
var fileName = _dialogService.ShowOpenFileDialog("标定文件|*.json");
if (fileName == null) return;
if (_calibrator.LoadCalibration(fileName))
{
StatusText = string.Format(Res.CalibrationStatusLoaded, fileName);
_dialogService.ShowInfo(Res.CalibrationLoadSuccess, Res.CalibrationSuccessTitle);
}
else
{
_dialogService.ShowError(Res.CalibrationLoadFailed, Res.CalibrationSuccessTitle);
}
}
public PointF ConvertPixelToWorld(PointF pixel) => _calibrator.PixelToWorld(pixel);
public Image<Bgr, byte>? CurrentImage => _currentImage;
public event EventHandler? ImageLoadedRequested;
private void RaiseEvent(EventHandler? handler) => handler?.Invoke(this, EventArgs.Empty);
private static BitmapSource MatToBitmapSource(Mat mat)
{
using var bitmap = mat.ToBitmap();
var hBitmap = bitmap.GetHbitmap();
try
{
return System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap(
hBitmap, IntPtr.Zero, System.Windows.Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
}
finally
{
DeleteObject(hBitmap);
}
}
[System.Runtime.InteropServices.DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr hObject);
}
@@ -0,0 +1,244 @@
using Emgu.CV;
using System.Collections.ObjectModel;
using System.IO;
using System.Windows.Media.Imaging;
using Res = XP.Camera.Calibration.Resources.CalibrationResources;
namespace XP.Camera.Calibration.ViewModels;
public class ChessboardCalibrationViewModel : BindableBase
{
private readonly ICalibrationDialogService _dialogService;
private readonly ChessboardCalibrator _calibrator = new();
private readonly ObservableCollection<string> _imagePaths = new();
private BitmapSource? _imageSource;
private string _statusText = Res.ChessboardStatusReady;
private int _chessboardWidth = 11;
private int _chessboardHeight = 8;
private float _squareSize = 15;
private int _selectedImageIndex = -1;
private bool _isCalibrating = false;
private double _progressValue = 0;
private string _progressText = "";
public ChessboardCalibrationViewModel(ICalibrationDialogService dialogService)
{
_dialogService = dialogService;
ImageFileNames = new ObservableCollection<string>();
AddImagesCommand = new DelegateCommand(AddImages);
ClearImagesCommand = new DelegateCommand(ClearImages, CanClearImages)
.ObservesProperty(() => ImageFileNames.Count);
CalibrateCommand = new DelegateCommand(async () => await CalibrateAsync(), CanCalibrate)
.ObservesProperty(() => ImageFileNames.Count)
.ObservesProperty(() => IsCalibrating);
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
LoadCalibrationCommand = new DelegateCommand(LoadCalibration);
UndistortImageCommand = new DelegateCommand(UndistortImage);
_calibrator.ProgressChanged += OnCalibrationProgressChanged;
}
public ObservableCollection<string> ImageFileNames { get; }
public BitmapSource? ImageSource { get => _imageSource; set => SetProperty(ref _imageSource, value); }
public string StatusText { get => _statusText; set => SetProperty(ref _statusText, value); }
public int ChessboardWidth { get => _chessboardWidth; set => SetProperty(ref _chessboardWidth, value); }
public int ChessboardHeight { get => _chessboardHeight; set => SetProperty(ref _chessboardHeight, value); }
public float SquareSize { get => _squareSize; set => SetProperty(ref _squareSize, value); }
public int SelectedImageIndex
{
get => _selectedImageIndex;
set { if (SetProperty(ref _selectedImageIndex, value) && value >= 0) LoadSelectedImage(value); }
}
public bool IsCalibrating { get => _isCalibrating; set => SetProperty(ref _isCalibrating, value); }
public double ProgressValue { get => _progressValue; set => SetProperty(ref _progressValue, value); }
public string ProgressText { get => _progressText; set => SetProperty(ref _progressText, value); }
public DelegateCommand AddImagesCommand { get; }
public DelegateCommand ClearImagesCommand { get; }
public DelegateCommand CalibrateCommand { get; }
public DelegateCommand SaveCalibrationCommand { get; }
public DelegateCommand LoadCalibrationCommand { get; }
public DelegateCommand UndistortImageCommand { get; }
private void AddImages()
{
var fileNames = _dialogService.ShowOpenMultipleFilesDialog("图像文件|*.jpg;*.png;*.bmp;*.tif");
if (fileNames == null) return;
foreach (var file in fileNames)
{
_imagePaths.Add(file);
ImageFileNames.Add(Path.GetFileName(file));
}
StatusText = string.Format(Res.ChessboardStatusAdded, _imagePaths.Count);
}
private bool CanClearImages() => ImageFileNames.Count > 0;
private void ClearImages()
{
_imagePaths.Clear();
ImageFileNames.Clear();
ImageSource = null;
StatusText = Res.ChessboardStatusCleared;
RaiseEvent(ImageClearedRequested);
}
private bool CanCalibrate() => ImageFileNames.Count >= 3 && !IsCalibrating;
private void OnCalibrationProgressChanged(int current, int total, string message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
ProgressValue = (double)current / total * 100;
if (message.Contains("检测角点"))
{
var match = System.Text.RegularExpressions.Regex.Match(message, @"\((\d+)/(\d+)\)");
ProgressText = match.Success
? string.Format(Res.ChessboardProgressDetecting, match.Groups[1].Value, match.Groups[2].Value)
: message;
}
else if (message.Contains("执行相机标定"))
ProgressText = Res.ChessboardProgressCalibrating;
else if (message.Contains("计算重投影误差"))
{
var match = System.Text.RegularExpressions.Regex.Match(message, @"\((\d+)/(\d+)\)");
ProgressText = match.Success
? string.Format(Res.ChessboardProgressCalculating, match.Groups[1].Value, match.Groups[2].Value)
: message;
}
else if (message.Contains("标定完成"))
ProgressText = Res.ChessboardProgressComplete;
else if (message.Contains("标定失败"))
ProgressText = Res.ChessboardProgressFailed;
else
ProgressText = message;
});
}
private async System.Threading.Tasks.Task CalibrateAsync()
{
if (_imagePaths.Count < 3)
{
_dialogService.ShowError(Res.ChessboardErrorMinImages, Res.ChessboardCalibrationComplete);
return;
}
IsCalibrating = true;
ProgressValue = 0;
ProgressText = Res.ChessboardProgressPreparing;
StatusText = Res.ChessboardStatusCalibrating;
try
{
await System.Threading.Tasks.Task.Run(() =>
{
if (_calibrator.CalibrateFromImages(new List<string>(_imagePaths), ChessboardWidth, ChessboardHeight, SquareSize, out string error))
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var imageErrors = new System.Text.StringBuilder();
for (int i = 0; i < _calibrator.PerImageErrors.Count; i++)
imageErrors.AppendLine(string.Format(Res.ChessboardImageError, i + 1, _calibrator.PerImageErrors[i]));
StatusText = string.Format(Res.ChessboardStatusSuccess, _calibrator.ReprojectionError, imageErrors.ToString());
_dialogService.ShowInfo(Res.ChessboardCalibrationComplete, Res.ChessboardSaveSuccess);
});
}
else
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
StatusText = string.Format(Res.ChessboardStatusFailed, error);
_dialogService.ShowError(error, Res.ChessboardCalibrationComplete);
});
}
});
}
finally
{
IsCalibrating = false;
ProgressValue = 0;
}
}
private void SaveCalibration()
{
var fileName = _dialogService.ShowSaveFileDialog("标定文件|*.json", "camera_calibration.json");
if (fileName == null) return;
try
{
_calibrator.SaveCalibration(fileName);
StatusText = string.Format(Res.ChessboardStatusSaved, fileName);
_dialogService.ShowInfo(Res.ChessboardSaveSuccess, Res.ChessboardCalibrationComplete);
}
catch (Exception ex)
{
_dialogService.ShowError($"保存失败: {ex.Message}", Res.ChessboardCalibrationComplete);
}
}
private void LoadCalibration()
{
var fileName = _dialogService.ShowOpenFileDialog("标定文件|*.json");
if (fileName == null) return;
if (_calibrator.LoadCalibration(fileName))
{
StatusText = string.Format(Res.ChessboardStatusLoaded, fileName);
_dialogService.ShowInfo(Res.ChessboardLoadSuccess, Res.ChessboardCalibrationComplete);
}
else
{
_dialogService.ShowError(Res.CalibrationLoadFailed, Res.ChessboardCalibrationComplete);
}
}
private void UndistortImage()
{
var fileName = _dialogService.ShowOpenFileDialog("图像文件|*.jpg;*.png;*.bmp;*.tif");
if (fileName == null) return;
using var image = CvInvoke.Imread(fileName);
var undistorted = _calibrator.UndistortImage(image);
ImageSource = MatToBitmapSource(undistorted);
StatusText = string.Format(Res.ChessboardStatusUndistorted, Path.GetFileName(fileName));
RaiseEvent(ImageLoadedRequested);
}
private void LoadSelectedImage(int index)
{
if (index < 0 || index >= _imagePaths.Count) return;
var img = _calibrator.DrawChessboardCorners(_imagePaths[index], ChessboardWidth, ChessboardHeight);
if (img != null)
{
ImageSource = MatToBitmapSource(img);
RaiseEvent(ImageLoadedRequested);
}
if (_calibrator.PerImageErrors.Count > index)
StatusText = string.Format(Res.ChessboardStatusImageError, index + 1, _calibrator.PerImageErrors[index]);
}
public event EventHandler? ImageLoadedRequested;
public event EventHandler? ImageClearedRequested;
private void RaiseEvent(EventHandler? handler) => handler?.Invoke(this, EventArgs.Empty);
private static BitmapSource MatToBitmapSource(Mat mat)
{
using var bitmap = mat.ToBitmap();
var hBitmap = bitmap.GetHbitmap();
try
{
return System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap(
hBitmap, IntPtr.Zero, System.Windows.Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
}
finally
{
DeleteObject(hBitmap);
}
}
[System.Runtime.InteropServices.DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr hObject);
}
+97 -124
View File
@@ -1,6 +1,6 @@
# XP.Camera 使用说明
基于 .NET 8 WPF 的工业相机控制类库,采用工厂模式 + 统一接口设计,支持多品牌相机扩展。当前已实现 Basler pylon SDK 驱动。
基于 .NET 8 WPF 的工业相机控制与标定类库,采用工厂模式 + 统一接口设计,支持多品牌相机扩展。当前已实现 Basler pylon SDK 驱动。
## 环境要求
@@ -12,16 +12,36 @@
```
XP.Camera/
├── ICameraController.cs # 控制器接口 + 工厂接口
├── CameraFactory.cs # 统一工厂(根据品牌创建控制器)
├── BaslerCameraController.cs # Basler 实现
├── CameraModels.cs # CameraInfo、ImageGrabbedEventArgs、GrabErrorEventArgs
── CameraExceptions.cs # CameraException、ConnectionLostException、DeviceNotFoundException
├── PixelConverter.cs # 像素数据 → WPF BitmapSource 转换工具
├── Core/ # 相机核心抽象
├── ICameraController.cs # 控制器接口 + 工厂接口
│ ├── CameraFactory.cs # 统一工厂(根据品牌创建控制器)
├── CameraModels.cs # CameraInfo、ImageGrabbedEventArgs、GrabErrorEventArgs
│ └── CameraExceptions.cs # CameraException、ConnectionLostException、DeviceNotFoundException
├── Basler/ # Basler 品牌实现
│ └── BaslerCameraController.cs # Basler pylon SDK 实现
├── Converters/ # 数据转换工具
│ └── PixelConverter.cs # 像素数据 → WPF BitmapSource 转换
├── Calibration/ # 相机标定模块
│ ├── CalibrationProcessor.cs # 九点标定(DLT 单应性矩阵,像素→世界坐标)
│ ├── ChessboardCalibrator.cs # 棋盘格标定(Zhang's 方法,内参 + 畸变校正)
│ ├── IDialogService.cs # ICalibrationDialogService 接口
│ ├── DefaultCalibrationDialogService.cs # 默认实现(标准 WPF MessageBox
│ ├── CalibrationLocalizedStrings.cs # XAML 本地化绑定辅助
│ ├── Controls/ # 标定 UI 控件(UserControl
│ │ ├── CalibrationControl.xaml/.cs # 九点标定界面
│ │ ├── ChessboardCalibrationControl.xaml/.cs # 棋盘格标定界面
│ │ └── ImageCanvasControl.xaml/.cs # 图像画布(缩放/平移)
│ ├── ViewModels/ # 标定视图模型
│ │ ├── CalibrationViewModel.cs
│ │ └── ChessboardCalibrationViewModel.cs
│ └── Resources/ # 本地化资源
│ ├── CalibrationResources.resx # 中文(默认)
│ ├── CalibrationResources.en-US.resx # 英文
│ └── CalibrationResources.Designer.cs
└── XP.Camera.csproj
```
所有类型统一`XP.Camera` 命名空间下。
所有相机核心类型在 `XP.Camera` 命名空间下,标定模块在 `XP.Camera.Calibration` 命名空间下。
## 项目引用
@@ -48,27 +68,12 @@ Console.WriteLine($"已连接: {info.ModelName} (SN: {info.SerialNumber})");
在 Prism / DI 容器中注册:
```csharp
// App.xaml.cs
var config = AppConfig.Load();
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
containerRegistry.RegisterSingleton<ICameraController>(() =>
new CameraFactory().CreateController(config.CameraType));
```
ViewModel 中注入使用:
```csharp
public class MyViewModel
{
private readonly ICameraController _camera;
public MyViewModel(ICameraController camera)
{
_camera = camera;
}
}
```
相机品牌通过配置文件 `config.json` 指定:
```json
@@ -82,60 +87,85 @@ public class MyViewModel
```csharp
_camera.ImageGrabbed += (s, e) =>
{
// PixelConverter 返回已 Freeze 的 BitmapSource,可跨线程传递
var bitmap = PixelConverter.ToBitmapSource(
e.PixelData, e.Width, e.Height, e.PixelFormat);
Application.Current.Dispatcher.Invoke(() =>
{
CameraImageSource = bitmap;
});
Application.Current.Dispatcher.Invoke(() => CameraImageSource = bitmap);
};
```
XAML 绑定:
```xml
<Image Source="{Binding CameraImageSource}" Stretch="Uniform" />
```
### 4. 软件触发采集流程
```csharp
camera.Open();
camera.SetExposureTime(10000); // 10ms
camera.StartGrabbing();
// 每次需要采集时调用(结果通过 ImageGrabbed 事件返回)
camera.ExecuteSoftwareTrigger();
camera.StopGrabbing();
camera.Close();
```
### 5. 实时连续采集(链式触发)
### 5. 使用标定模块
收到上一帧后立即触发下一帧,自动适配任何帧率:
标定模块完全自包含,可独立使用,无需外部依赖。
#### 棋盘格标定(相机内参 + 畸变校正)
```csharp
private volatile bool _liveViewRunning;
using XP.Camera.Calibration;
using XP.Camera.Calibration.ViewModels;
using XP.Camera.Calibration.Controls;
_camera.ImageGrabbed += (s, e) =>
// 使用默认对话框服务(标准 WPF MessageBox
var dialogService = new DefaultCalibrationDialogService();
var viewModel = new ChessboardCalibrationViewModel(dialogService);
var window = new Window
{
var bitmap = PixelConverter.ToBitmapSource(e.PixelData, e.Width, e.Height, e.PixelFormat);
Application.Current.Dispatcher.Invoke(() => CameraImageSource = bitmap);
if (_liveViewRunning)
_camera.ExecuteSoftwareTrigger(); // 链式触发下一帧
Title = "棋盘格标定",
Width = 1600, Height = 900,
Content = new ChessboardCalibrationControl { DataContext = viewModel }
};
window.ShowDialog();
```
// 启动实时
_camera.StartGrabbing();
_liveViewRunning = true;
_camera.ExecuteSoftwareTrigger(); // 触发第一帧
#### 九点标定(像素→世界坐标)
// 停止实时
_liveViewRunning = false;
```csharp
var dialogService = new DefaultCalibrationDialogService();
var viewModel = new CalibrationViewModel(dialogService);
var window = new Window
{
Title = "九点标定",
Width = 1400, Height = 850,
Content = new CalibrationControl { DataContext = viewModel }
};
window.ShowDialog();
```
#### 纯算法调用(不使用 UI
```csharp
// 棋盘格标定
var calibrator = new ChessboardCalibrator();
calibrator.CalibrateFromImages(imagePaths, boardWidth: 11, boardHeight: 8, squareSize: 15f, out string error);
calibrator.SaveCalibration("camera_calibration.json");
// 九点标定
var processor = new CalibrationProcessor();
processor.Calibrate(points);
var worldPoint = processor.PixelToWorld(new PointF(100, 200));
```
#### 自定义对话框服务
如需自定义弹框样式,实现 `ICalibrationDialogService` 接口即可:
```csharp
public class MyDialogService : ICalibrationDialogService
{
// 实现所有接口方法,使用自定义 UI 组件...
}
```
## 核心接口
@@ -149,94 +179,37 @@ _liveViewRunning = false;
| `StartGrabbing()` | 以软件触发模式启动采集 |
| `ExecuteSoftwareTrigger()` | 触发一帧采集 |
| `StopGrabbing()` | 停止采集 |
| `Get/SetExposureTime` | 曝光时间(微秒) |
| `Get/SetGain` | 增益值 |
| `Get/SetWidth/Height` | 图像尺寸 |
| `Get/SetPixelFormat` | 像素格式(Mono8 / BGR8 / BGRA8 |
### 参数读写
### 事件
| 方法 | 说明 |
| 事件 | 说明 |
|------|------|
| `Get/SetExposureTime(double)` | 曝光时间(微秒) |
| `Get/SetGain(double)` | 增益值 |
| `Get/SetWidth(int)` | 图像宽度(自动校正到有效值) |
| `Get/SetHeight(int)` | 图像高度(自动校正到有效值) |
| `Get/SetPixelFormat(string)` | 像素格式(Mono8 / BGR8 / BGRA8 |
| `ImageGrabbed` | 成功采集一帧图像 |
| `GrabError` | 图像采集失败 |
| `ConnectionLost` | 相机连接意外断开 |
### ICameraFactory
| 方法 | 说明 |
|------|------|
| `CreateController(string cameraType)` | 根据品牌名创建控制器 |
当前支持的 `cameraType` 值:`"Basler"`
## 事件
| 事件 | 说明 | 触发线程 |
|------|------|----------|
| `ImageGrabbed` | 成功采集一帧图像 | StreamGrabber 回调线程 |
| `GrabError` | 图像采集失败 | StreamGrabber 回调线程 |
| `ConnectionLost` | 相机连接意外断开 | pylon SDK 事件线程 |
> 所有事件均在非 UI 线程触发。更新 WPF 界面时需通过 `Dispatcher.Invoke` 调度。
> `PixelConverter.ToBitmapSource()` 返回的 BitmapSource 已调用 `Freeze()`,可直接跨线程传递。
> 所有事件均在非 UI 线程触发,更新 WPF 界面时需通过 `Dispatcher.Invoke` 调度。
## 异常处理
```csharp
try
{
camera.Open();
}
catch (DeviceNotFoundException)
{
// 无可用相机设备
}
catch (CameraException ex)
{
// 其他相机错误,ex.InnerException 包含原始 SDK 异常
}
```
| 异常类型 | 场景 |
|---------|------|
| `DeviceNotFoundException` | 无可用相机 |
| `ConnectionLostException` | 相机物理断开 |
| `CameraException` | SDK 操作失败(基类) |
| `InvalidOperationException` | 未连接时访问参数,未采集时触发 |
| `TimeoutException` | 软件触发等待超时 |
## 扩展其他品牌相机
1. 实现 `ICameraController` 接口
```csharp
public class HikvisionCameraController : ICameraController
{
// 实现所有接口方法...
}
```
2. 在 `CameraFactory.cs` 中注册:
```csharp
public ICameraController CreateController(string cameraType)
{
return cameraType switch
{
"Basler" => new BaslerCameraController(),
"Hikvision" => new HikvisionCameraController(),
_ => throw new NotSupportedException($"不支持的相机品牌: {cameraType}")
};
}
```
3. 配置文件切换品牌即可,业务代码无需修改。
1. `Basler/` 同级创建新文件夹,实现 `ICameraController` 接口
2. 在 `CameraFactory.cs` 中注册新品牌
3. 配置文件切换品牌即可,业务代码无需修改
## 线程安全
- 所有公共方法Open / Close / StartGrabbing / StopGrabbing / ExecuteSoftwareTrigger / 参数读写)均线程安全
- 所有公共方法均线程安全
- 事件回调不持有内部锁,不会导致死锁
- `Open()` / `Close()` 幂等,重复调用安全
## 日志
使用 Serilog 静态 API`Log.ForContext<T>()`),与宿主应用共享同一个日志管道。宿主应用只需在启动时配置 `Log.Logger` 即可。
+17
View File
@@ -13,7 +13,24 @@
<Reference Include="Basler.Pylon">
<HintPath>..\ExternalLibraries\Basler.Pylon.dll</HintPath>
</Reference>
<PackageReference Include="Emgu.CV" Version="4.10.0.5680" />
<PackageReference Include="Emgu.CV.runtime.windows" Version="4.10.0.5680" />
<PackageReference Include="Emgu.CV.Bitmap" Version="4.10.0.5680" />
<PackageReference Include="MahApps.Metro.IconPacks" Version="6.2.1" />
<PackageReference Include="Prism.DryIoc" Version="9.0.537" />
<PackageReference Include="Prism.Wpf" Version="9.0.537" />
<PackageReference Include="Serilog" Version="4.3.1" />
<ProjectReference Include="..\XP.Common\XP.Common.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Calibration\Resources\CalibrationResources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>CalibrationResources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Calibration\Resources\CalibrationResources.en-US.resx">
<DependentUpon>CalibrationResources.resx</DependentUpon>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -0,0 +1,114 @@
<Window x:Class="XP.Common.GeneralForm.Views.HexMessageBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="240" Width="400"
MinHeight="240" MinWidth="400"
MaxHeight="240" MaxWidth="400"
WindowStartupLocation="Manual"
Topmost="True"
WindowStyle="None"
x:Name="csdMessageBox"
Background="White">
<Window.Resources>
<Style x:Key="MenuButton" TargetType="Button">
<Setter Property="MinWidth" Value="80" />
<Setter Property="Height" Value="30" />
<Setter Property="Background" Value="#0078D7" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="2" Padding="10,5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#005A9E" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#004275" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Border BorderBrush="#CCCCCC" BorderThickness="1">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Image Grid.Column="0" Source="{Binding Icon, ElementName=csdMessageBox}"
Width="34" Height="33" Margin="0,0,10,0" VerticalAlignment="Top" />
<ScrollViewer Grid.Column="1" VerticalScrollBarVisibility="Auto" MaxHeight="140">
<TextBlock Text="{Binding MessageText, ElementName=csdMessageBox}"
TextWrapping="Wrap" FontSize="14" VerticalAlignment="Center" />
</ScrollViewer>
</Grid>
<Rectangle Grid.Row="1" Fill="#CCCCCC" Height="1" />
<Grid Grid.Row="2" Margin="10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding MessageBoxButton, ElementName=csdMessageBox}" Value="{x:Static MessageBoxButton.YesNo}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Button Content="Yes" Style="{StaticResource MenuButton}" Margin="0,0,10,0" Click="Yes_Click" />
<Button Content="No" Style="{StaticResource MenuButton}" Click="No_Click" />
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding MessageBoxButton, ElementName=csdMessageBox}" Value="{x:Static MessageBoxButton.OKCancel}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Button Content="OK" Style="{StaticResource MenuButton}" Margin="0,0,10,0" Click="OK_Click" />
<Button Content="Cancel" Style="{StaticResource MenuButton}" Click="Cancel_Click" />
</StackPanel>
<StackPanel HorizontalAlignment="Right">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding MessageBoxButton, ElementName=csdMessageBox}" Value="{x:Static MessageBoxButton.OK}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<Button Content="OK" Style="{StaticResource MenuButton}" Click="OK_Click" />
</StackPanel>
</Grid>
</Grid>
</Border>
</Window>
@@ -0,0 +1,109 @@
using System;
using System.Drawing;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace XP.Common.GeneralForm.Views;
/// <summary>
/// 自定义消息框,可替代标准 MessageBox
/// </summary>
public partial class HexMessageBox : Window
{
public MessageBoxResult Result { get; set; }
public string MessageText
{
get => (string)GetValue(MessageTextProperty);
set => SetValue(MessageTextProperty, value);
}
public static readonly DependencyProperty MessageTextProperty =
DependencyProperty.Register("MessageText", typeof(string), typeof(HexMessageBox), new PropertyMetadata(""));
public new ImageSource Icon
{
get => (ImageSource)GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public new static readonly DependencyProperty IconProperty =
DependencyProperty.Register("Icon", typeof(ImageSource), typeof(HexMessageBox), new PropertyMetadata(null));
public MessageBoxButton MessageBoxButton
{
get => (MessageBoxButton)GetValue(MessageBoxButtonProperty);
set => SetValue(MessageBoxButtonProperty, value);
}
public static readonly DependencyProperty MessageBoxButtonProperty =
DependencyProperty.Register("MessageBoxButton", typeof(MessageBoxButton), typeof(HexMessageBox), new PropertyMetadata(MessageBoxButton.OK));
public HexMessageBox(string messageText, MessageBoxButton messageBoxButton, MessageBoxImage image)
{
InitializeComponent();
Left = (SystemParameters.PrimaryScreenWidth - Width) / 2;
Top = (SystemParameters.PrimaryScreenHeight - Height) / 2;
MessageText = messageText;
IntPtr iconHandle = SystemIcons.Information.Handle;
switch (image)
{
case MessageBoxImage.Error:
iconHandle = SystemIcons.Error.Handle;
break;
case MessageBoxImage.Question:
iconHandle = SystemIcons.Question.Handle;
break;
case MessageBoxImage.Warning:
iconHandle = SystemIcons.Warning.Handle;
break;
case MessageBoxImage.Information:
iconHandle = SystemIcons.Information.Handle;
break;
}
Icon = Imaging.CreateBitmapSourceFromHIcon(iconHandle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
Icon.Freeze();
MessageBoxButton = messageBoxButton;
Result = MessageBoxResult.None;
Topmost = true;
}
/// <summary>
/// 显示模态对话框并返回结果
/// </summary>
public static MessageBoxResult Show(string messageText, MessageBoxButton messageBoxButton, MessageBoxImage image)
{
return Application.Current.Dispatcher.Invoke(() =>
{
var window = new HexMessageBox(messageText, messageBoxButton, image);
window.ShowDialog();
return window.Result;
});
}
/// <summary>
/// 显示非模态消息
/// </summary>
public static HexMessageBox ShowMessage(string messageText, MessageBoxImage image)
{
HexMessageBox? messageBox = null;
Application.Current.Dispatcher.Invoke(() =>
{
messageBox = new HexMessageBox(messageText, MessageBoxButton.OK, image);
messageBox.Show();
});
return messageBox!;
}
private void Yes_Click(object sender, RoutedEventArgs e) { Result = MessageBoxResult.Yes; Close(); }
private void No_Click(object sender, RoutedEventArgs e) { Result = MessageBoxResult.No; Close(); }
private void OK_Click(object sender, RoutedEventArgs e) { Result = MessageBoxResult.OK; Close(); }
private void Cancel_Click(object sender, RoutedEventArgs e) { Result = MessageBoxResult.Cancel; Close(); }
}
+1 -1
View File
@@ -1,4 +1,4 @@
using System.Configuration;
using System.Configuration;
using XP.Common.Configs;
using XP.Common.Dump.Configs;
@@ -1878,7 +1878,10 @@
"Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408"
},
"runtime": {
"XP.Common.dll": {}
"XP.Common.dll": {
"assemblyVersion": "1.4.16.1",
"fileVersion": "1.4.16.1"
}
},
"resources": {
"en-US/XP.Common.resources.dll": {
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+1 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
+1 -1
View File
@@ -1,4 +1,4 @@
using Prism.Mvvm;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -1,4 +1,4 @@
using Prism.Ioc;
using Prism.Ioc;
using Prism.Modularity;
using System.Resources;
using XP.Common.Localization;
@@ -1,340 +0,0 @@
using Moq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using XP.Common.Configs;
using XP.Common.Database.Implementations;
using XP.Common.Database.Interfaces;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.InspectionResults;
using Xunit;
namespace XplorePlane.Tests.Services
{
public class InspectionResultStoreTests : IDisposable
{
private readonly string _tempRoot;
private readonly Mock<ILoggerService> _mockLogger;
private readonly IDbContext _dbContext;
private readonly InspectionResultStore _store;
public InspectionResultStoreTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "XplorePlane.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
_mockLogger = new Mock<ILoggerService>();
_mockLogger.Setup(l => l.ForModule(It.IsAny<string>())).Returns(_mockLogger.Object);
_mockLogger.Setup(l => l.ForModule<InspectionResultStore>()).Returns(_mockLogger.Object);
var sqliteConfig = new SqliteConfig
{
DbFilePath = Path.Combine(_tempRoot, "inspection-results.db"),
CreateIfNotExists = true,
EnableWalMode = false,
EnableSqlLogging = false
};
_dbContext = new SqliteContext(sqliteConfig, _mockLogger.Object);
_store = new InspectionResultStore(_dbContext, _mockLogger.Object, Path.Combine(_tempRoot, "assets"));
}
[Fact]
public async Task FullRun_WithTwoNodes_CanRoundTripDetailAndQuery()
{
var startedAt = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
var run = new InspectionRunRecord
{
ProgramName = "NewCncProgram",
WorkpieceId = "QFN_1",
SerialNumber = "SN-001",
StartedAt = startedAt
};
var runSource = CreateTempFile("run-source.bmp", "run-source");
await _store.BeginRunAsync(run, new InspectionAssetWriteRequest
{
AssetType = InspectionAssetType.RunSourceImage,
SourceFilePath = runSource,
FileFormat = "bmp"
});
var pipelineA = BuildPipeline("Recipe-A", ("GaussianBlur", 0), ("Threshold", 1));
var node1Id = Guid.NewGuid();
await _store.AppendNodeResultAsync(
new InspectionNodeResult
{
RunId = run.RunId,
NodeId = node1Id,
NodeIndex = 1,
NodeName = "检测节点1",
PipelineId = pipelineA.Id,
PipelineName = pipelineA.Name,
NodePass = true,
DurationMs = 135
},
new[]
{
new InspectionMetricResult
{
MetricKey = "bridge.rate",
MetricName = "Bridge Rate",
MetricValue = 0.12,
Unit = "%",
UpperLimit = 0.2,
IsPass = true,
DisplayOrder = 1
},
new InspectionMetricResult
{
MetricKey = "void.area",
MetricName = "Void Area",
MetricValue = 5.6,
Unit = "px",
UpperLimit = 8,
IsPass = true,
DisplayOrder = 2
}
},
new PipelineExecutionSnapshot
{
PipelineName = pipelineA.Name,
PipelineDefinitionJson = JsonSerializer.Serialize(pipelineA)
},
new[]
{
new InspectionAssetWriteRequest
{
AssetType = InspectionAssetType.NodeInputImage,
SourceFilePath = CreateTempFile("node1-input.bmp", "node1-input"),
FileFormat = "bmp"
},
new InspectionAssetWriteRequest
{
AssetType = InspectionAssetType.NodeResultImage,
SourceFilePath = CreateTempFile("node1-result.bmp", "node1-result"),
FileFormat = "bmp"
}
});
var pipelineB = BuildPipeline("Recipe-B", ("MeanFilter", 0), ("ContourDetection", 1));
var node2Id = Guid.NewGuid();
await _store.AppendNodeResultAsync(
new InspectionNodeResult
{
RunId = run.RunId,
NodeId = node2Id,
NodeIndex = 2,
NodeName = "检测节点2",
PipelineId = pipelineB.Id,
PipelineName = pipelineB.Name,
NodePass = false,
Status = InspectionNodeStatus.Failed,
DurationMs = 240
},
new[]
{
new InspectionMetricResult
{
MetricKey = "solder.height",
MetricName = "Solder Height",
MetricValue = 1.7,
Unit = "mm",
LowerLimit = 1.8,
IsPass = false,
DisplayOrder = 1
}
},
new PipelineExecutionSnapshot
{
PipelineName = pipelineB.Name,
PipelineDefinitionJson = JsonSerializer.Serialize(pipelineB)
},
new[]
{
new InspectionAssetWriteRequest
{
AssetType = InspectionAssetType.NodeResultImage,
SourceFilePath = CreateTempFile("node2-result.bmp", "node2-result"),
FileFormat = "bmp"
}
});
await _store.CompleteRunAsync(run.RunId);
var queried = await _store.QueryRunsAsync(new InspectionRunQuery
{
ProgramName = "NewCncProgram",
WorkpieceId = "QFN_1",
PipelineName = "Recipe-A"
});
var detail = await _store.GetRunDetailAsync(run.RunId);
Assert.Single(queried);
Assert.Equal(run.RunId, queried[0].RunId);
Assert.False(detail.Run.OverallPass);
Assert.Equal(2, detail.Run.NodeCount);
Assert.Equal(2, detail.Nodes.Count);
Assert.Equal(3, detail.Metrics.Count);
Assert.Equal(4, detail.Assets.Count);
Assert.Equal(2, detail.PipelineSnapshots.Count);
Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass);
Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass);
Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash)));
var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json");
Assert.True(File.Exists(manifestPath));
}
[Fact]
public async Task AppendNodeResult_MissingAsset_DoesNotCrashAndMarksAssetMissing()
{
var run = new InspectionRunRecord
{
ProgramName = "Program-A",
WorkpieceId = "Part-01",
SerialNumber = "SN-404"
};
await _store.BeginRunAsync(run);
var nodeId = Guid.NewGuid();
await _store.AppendNodeResultAsync(
new InspectionNodeResult
{
RunId = run.RunId,
NodeId = nodeId,
NodeIndex = 1,
NodeName = "缺图节点",
PipelineId = Guid.NewGuid(),
PipelineName = "Recipe-Missing",
NodePass = true
},
new[]
{
new InspectionMetricResult
{
MetricKey = "metric.only",
MetricName = "Metric Only",
MetricValue = 1,
Unit = "pcs",
IsPass = true,
DisplayOrder = 1
}
},
new PipelineExecutionSnapshot
{
PipelineName = "Recipe-Missing",
PipelineDefinitionJson = "{\"nodes\":[\"gaussian\"]}"
},
new[]
{
new InspectionAssetWriteRequest
{
AssetType = InspectionAssetType.NodeResultImage,
SourceFilePath = Path.Combine(_tempRoot, "missing-file.bmp"),
FileFormat = "bmp"
}
});
var detail = await _store.GetRunDetailAsync(run.RunId);
var node = Assert.Single(detail.Nodes);
Assert.Equal(InspectionNodeStatus.AssetMissing, node.Status);
Assert.Single(detail.Metrics);
Assert.Empty(detail.Assets);
}
[Fact]
public async Task PipelineSnapshot_IsStoredAsExecutionSnapshot_NotDependentOnLaterChanges()
{
var run = new InspectionRunRecord
{
ProgramName = "Program-Snapshot",
WorkpieceId = "Part-02",
SerialNumber = "SN-SNAP"
};
await _store.BeginRunAsync(run);
var pipeline = BuildPipeline("Recipe-Snapshot", ("GaussianBlur", 0), ("ContourDetection", 1));
var snapshotJson = JsonSerializer.Serialize(pipeline);
var originalHash = ComputeExpectedHash(snapshotJson);
await _store.AppendNodeResultAsync(
new InspectionNodeResult
{
RunId = run.RunId,
NodeId = Guid.NewGuid(),
NodeIndex = 1,
NodeName = "快照节点",
PipelineId = pipeline.Id,
PipelineName = pipeline.Name,
NodePass = true
},
pipelineSnapshot: new PipelineExecutionSnapshot
{
PipelineName = pipeline.Name,
PipelineDefinitionJson = snapshotJson
});
pipeline.Name = "Recipe-Snapshot-Changed";
pipeline.Nodes[0].OperatorKey = "MeanFilter";
var detail = await _store.GetRunDetailAsync(run.RunId);
var snapshot = Assert.Single(detail.PipelineSnapshots);
Assert.Equal("Recipe-Snapshot", snapshot.PipelineName);
Assert.Equal(snapshotJson, snapshot.PipelineDefinitionJson);
Assert.Equal(originalHash, snapshot.PipelineHash);
}
public void Dispose()
{
_dbContext.Dispose();
if (Directory.Exists(_tempRoot))
{
try
{
Directory.Delete(_tempRoot, true);
}
catch (IOException)
{
// SQLite file handles may release slightly after test teardown.
}
}
}
private string CreateTempFile(string fileName, string content)
{
var path = Path.Combine(_tempRoot, fileName);
File.WriteAllText(path, content);
return path;
}
private static PipelineModel BuildPipeline(string name, params (string OperatorKey, int Order)[] nodes)
{
return new PipelineModel
{
Name = name,
Nodes = nodes.Select(node => new PipelineNodeModel
{
OperatorKey = node.OperatorKey,
Order = node.Order
}).ToList()
};
}
private static string ComputeExpectedHash(string value)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(sha.ComputeHash(bytes));
}
}
}
@@ -34,4 +34,11 @@
<ProjectReference Include="..\XplorePlane\XplorePlane.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="ImageProcessing.Core">
<HintPath>..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
</Project>
+74 -11
View File
@@ -13,6 +13,7 @@ using XP.Camera;
using XP.Common.Configs;
using XP.Common.Database.Implementations;
using XP.Common.Database.Interfaces;
using XP.Common.GeneralForm.Views;
using XP.Common.Dump.Configs;
using XP.Common.Dump.Implementations;
using XP.Common.Dump.Interfaces;
@@ -36,7 +37,6 @@ using XplorePlane.Services.Camera;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.Matrix;
using XplorePlane.Services.Measurement;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.Recipe;
using XplorePlane.ViewModels;
using XplorePlane.ViewModels.Cnc;
@@ -150,7 +150,23 @@ namespace XplorePlane
Log.Error(ex, "射线源资源释放失败");
}
// 释放相机服务资源
// 先停止导航相机实时采集,再释放资源,避免回调死锁
try
{
var bootstrapper = AppBootstrapper.Instance;
if (bootstrapper != null)
{
var cameraVm = bootstrapper.Container.Resolve<NavigationPropertyPanelViewModel>();
cameraVm?.Dispose();
Log.Information("导航相机 ViewModel 已释放");
}
}
catch (Exception ex)
{
Log.Error(ex, "导航相机 ViewModel 释放失败");
}
// 释放导航相机服务资源
try
{
var bootstrapper = AppBootstrapper.Instance;
@@ -158,12 +174,12 @@ namespace XplorePlane
{
var cameraService = bootstrapper.Container.Resolve<ICameraService>();
cameraService?.Dispose();
Log.Information("相机服务资源已释放");
Log.Information("导航相机服务资源已释放");
}
}
catch (Exception ex)
{
Log.Error(ex, "相机服务资源释放失败");
Log.Error(ex, "导航相机服务资源释放失败");
}
// 释放SQLite数据库资源 | Release SQLite database resources
@@ -232,19 +248,67 @@ namespace XplorePlane
private bool _modulesInitialized = false;
private string? _cameraError;
protected override Window CreateShell()
{
// 提前初始化模块,确保硬件服务在 MainWindow XAML 解析前已注册
// 默认 Prism 顺序是 CreateShell → InitializeModules
// 但 MainWindow 中嵌入的硬件控件会在 XAML 解析时触发 ViewModelLocator
// 此时模块尚未加载,导致依赖解析失败
if (!_modulesInitialized)
{
base.InitializeModules();
_modulesInitialized = true;
}
return Container.Resolve<MainWindow>();
var shell = Container.Resolve<MainWindow>();
// 主窗口加载完成后再连接相机,确保所有模块和原生 DLL 已完成初始化
shell.Loaded += (s, e) =>
{
TryConnectCamera();
// 通知 ViewModel 相机状态已确定,启动实时预览或显示错误
try
{
var cameraVm = Container.Resolve<NavigationPropertyPanelViewModel>();
cameraVm.OnCameraReady();
}
catch (Exception ex)
{
Log.Error(ex, "通知相机 ViewModel 失败");
}
if (_cameraError != null)
{
HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
}
};
return shell;
}
/// <summary>
/// 在主线程上检索并连接导航相机。
/// pylon SDK 要求在主线程(STA)上操作,不能放到后台线程。
/// </summary>
private void TryConnectCamera()
{
var camera = Container.Resolve<ICameraController>();
try
{
var info = camera.Open();
Log.Information("导航相机已连接: {ModelName} (SN: {SerialNumber})", info.ModelName, info.SerialNumber);
}
catch (DeviceNotFoundException)
{
Log.Warning("未检测到导航相机");
_cameraError = "未检测到导航相机,请检查连接后重启软件。";
}
catch (Exception ex)
{
Log.Warning(ex, "导航相机自动连接失败: {Message}", ex.Message);
_cameraError = $"导航相机连接失败: {ex.Message}";
}
}
/// <summary>
@@ -318,7 +382,6 @@ namespace XplorePlane
containerRegistry.RegisterSingleton<ICncProgramService, CncProgramService>();
containerRegistry.RegisterSingleton<IMatrixService, MatrixService>();
containerRegistry.RegisterSingleton<IMeasurementDataService, MeasurementDataService>();
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
// ── CNC / 矩阵 ViewModel(瞬态)──
containerRegistry.Register<CncEditorViewModel>();
@@ -329,7 +392,7 @@ namespace XplorePlane
containerRegistry.RegisterForNavigation<CncPageView>();
containerRegistry.RegisterForNavigation<MatrixPageView>();
// ── 相机服务(单例)──
// ── 导航相机服务(单例)──
containerRegistry.RegisterSingleton<ICameraFactory, CameraFactory>();
containerRegistry.RegisterSingleton<ICameraController>(() =>
new CameraFactory().CreateController("Basler"));
@@ -356,4 +419,4 @@ namespace XplorePlane
base.ConfigureModuleCatalog(moduleCatalog);
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+2 -3
View File
@@ -46,8 +46,7 @@ namespace XplorePlane.Models
/// <summary>参考点节点 | Reference point node</summary>
public record ReferencePointNode(
Guid Id, int Index, string Name,
double XM, double YM, double ZT, double ZD, double TiltD, double Dist,
bool IsRayOn, double Voltage, double Current
double XM, double YM, double ZT, double ZD, double TiltD, double Dist
) : CncNode(Id, Index, CncNodeType.ReferencePoint, Name);
/// <summary>保存节点(含图像)| Save node with image</summary>
@@ -114,4 +113,4 @@ namespace XplorePlane.Models
DateTime UpdatedAt,
IReadOnlyList<CncNode> Nodes
);
}
}
@@ -1,116 +0,0 @@
using System;
using System.Collections.Generic;
namespace XplorePlane.Models
{
public enum InspectionAssetType
{
RunSourceImage,
NodeInputImage,
NodeResultImage
}
public enum InspectionNodeStatus
{
Succeeded,
Failed,
PartialSuccess,
AssetMissing
}
public class InspectionRunRecord
{
public Guid RunId { get; set; } = Guid.NewGuid();
public string ProgramName { get; set; } = string.Empty;
public string WorkpieceId { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public bool OverallPass { get; set; }
public string SourceImagePath { get; set; } = string.Empty;
public string ResultRootPath { get; set; } = string.Empty;
public int NodeCount { get; set; }
}
public class InspectionNodeResult
{
public Guid RunId { get; set; }
public Guid NodeId { get; set; } = Guid.NewGuid();
public int NodeIndex { get; set; }
public string NodeName { get; set; } = string.Empty;
public Guid PipelineId { get; set; }
public string PipelineName { get; set; } = string.Empty;
public string PipelineVersionHash { get; set; } = string.Empty;
public bool NodePass { get; set; }
public string SourceImagePath { get; set; } = string.Empty;
public string ResultImagePath { get; set; } = string.Empty;
public InspectionNodeStatus Status { get; set; } = InspectionNodeStatus.Succeeded;
public long DurationMs { get; set; }
}
public class InspectionMetricResult
{
public Guid RunId { get; set; }
public Guid NodeId { get; set; }
public string MetricKey { get; set; } = string.Empty;
public string MetricName { get; set; } = string.Empty;
public double MetricValue { get; set; }
public string Unit { get; set; } = string.Empty;
public double? LowerLimit { get; set; }
public double? UpperLimit { get; set; }
public bool IsPass { get; set; }
public int DisplayOrder { get; set; }
}
public class InspectionAssetRecord
{
public Guid RunId { get; set; }
public Guid? NodeId { get; set; }
public InspectionAssetType AssetType { get; set; }
public string RelativePath { get; set; } = string.Empty;
public string FileFormat { get; set; } = string.Empty;
public int Width { get; set; }
public int Height { get; set; }
}
public class PipelineExecutionSnapshot
{
public Guid RunId { get; set; }
public Guid NodeId { get; set; }
public string PipelineName { get; set; } = string.Empty;
public string PipelineDefinitionJson { get; set; } = string.Empty;
public string PipelineHash { get; set; } = string.Empty;
}
public class InspectionAssetWriteRequest
{
public InspectionAssetType AssetType { get; set; }
public string FileName { get; set; } = string.Empty;
public string SourceFilePath { get; set; } = string.Empty;
public byte[] Content { get; set; }
public string FileFormat { get; set; } = string.Empty;
public int Width { get; set; }
public int Height { get; set; }
}
public class InspectionRunQuery
{
public string ProgramName { get; set; } = string.Empty;
public string WorkpieceId { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public string PipelineName { get; set; } = string.Empty;
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public int? Skip { get; set; }
public int? Take { get; set; }
}
public class InspectionRunDetail
{
public InspectionRunRecord Run { get; set; } = new();
public IReadOnlyList<InspectionNodeResult> Nodes { get; set; } = Array.Empty<InspectionNodeResult>();
public IReadOnlyList<InspectionMetricResult> Metrics { get; set; } = Array.Empty<InspectionMetricResult>();
public IReadOnlyList<InspectionAssetRecord> Assets { get; set; } = Array.Empty<InspectionAssetRecord>();
public IReadOnlyList<PipelineExecutionSnapshot> PipelineSnapshots { get; set; } = Array.Empty<PipelineExecutionSnapshot>();
}
}
+2 -56
View File
@@ -6,7 +6,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
@@ -21,7 +20,6 @@ namespace XplorePlane.Services.Cnc
public class CncProgramService : ICncProgramService
{
private readonly IAppStateService _appStateService;
private readonly IRaySourceService _raySourceService;
private readonly ILoggerService _logger;
// ── 序列化配置 | Serialization options ──
@@ -34,15 +32,12 @@ namespace XplorePlane.Services.Cnc
public CncProgramService(
IAppStateService appStateService,
IRaySourceService raySourceService,
ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(appStateService);
ArgumentNullException.ThrowIfNull(raySourceService);
ArgumentNullException.ThrowIfNull(logger);
_appStateService = appStateService;
_raySourceService = raySourceService;
_logger = logger.ForModule<CncProgramService>();
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
@@ -205,32 +200,6 @@ namespace XplorePlane.Services.Cnc
return updated;
}
/// <inheritdoc />
public CncProgram UpdateNode(CncProgram program, int index, CncNode node)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(node);
if (index < 0 || index >= program.Nodes.Count)
throw new ArgumentOutOfRangeException(nameof(index),
$"Index out of range: {index}, Count={program.Nodes.Count}");
var nodes = new List<CncNode>(program.Nodes)
{
[index] = node with { Index = index }
};
var updated = program with
{
Nodes = nodes.AsReadOnly(),
UpdatedAt = DateTime.UtcNow
};
_logger.Info("Updated node: Index={Index}, Type={NodeType}, Program={ProgramName}",
index, node.NodeType, program.Name);
return updated;
}
/// <inheritdoc />
public async Task SaveAsync(CncProgram program, string filePath)
{
@@ -375,7 +344,6 @@ namespace XplorePlane.Services.Cnc
private ReferencePointNode CreateReferencePointNode(Guid id, int index)
{
var motion = _appStateService.MotionState;
var raySource = _appStateService.RaySourceState;
return new ReferencePointNode(
id, index, $"参考点_{index}",
XM: motion.XM,
@@ -383,10 +351,7 @@ namespace XplorePlane.Services.Cnc
ZT: motion.ZT,
ZD: motion.ZD,
TiltD: motion.TiltD,
Dist: motion.Dist,
IsRayOn: raySource.IsOn,
Voltage: raySource.Voltage,
Current: TryReadCurrent());
Dist: motion.Dist);
}
/// <summary>创建保存节点(含图像)| Create save node with image</summary>
@@ -417,24 +382,5 @@ namespace XplorePlane.Services.Cnc
id, index, $"保存位置_{index}",
MotionState: _appStateService.MotionState);
}
private double TryReadCurrent()
{
try
{
var result = _raySourceService.ReadCurrent();
if (result?.Success == true)
{
return result.GetFloat();
}
_logger.Warn("Failed to read ray source current, ReferencePoint node will use 0");
}
catch (Exception ex)
{
_logger.Warn("Failed to read ray source current: {Message}", ex.Message);
}
return 0d;
}
}
}
}
+12 -4
View File
@@ -4,28 +4,36 @@ using XplorePlane.Models;
namespace XplorePlane.Services.Cnc
{
/// <summary>
/// CNC program management service interface.
/// CNC 程序管理服务接口,负责程序的创建、节点编辑、序列化/反序列化和文件读写
/// CNC program management service interface for creation, node editing, serialization and file I/O
/// </summary>
public interface ICncProgramService
{
/// <summary>创建空的 CNC 程序 | Create an empty CNC program</summary>
CncProgram CreateProgram(string name);
/// <summary>根据节点类型创建节点(从 IAppStateService 捕获设备状态)| Create a node by type (captures device state from IAppStateService)</summary>
CncNode CreateNode(CncNodeType type);
/// <summary>在指定索引之后插入节点并重新编号 | Insert a node after the given index and renumber</summary>
CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node);
/// <summary>移除指定索引的节点并重新编号 | Remove the node at the given index and renumber</summary>
CncProgram RemoveNode(CncProgram program, int index);
/// <summary>将节点从旧索引移动到新索引并重新编号 | Move a node from old index to new index and renumber</summary>
CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex);
CncProgram UpdateNode(CncProgram program, int index, CncNode node);
/// <summary>将 CNC 程序保存到 .xp 文件 | Save CNC program to .xp file</summary>
Task SaveAsync(CncProgram program, string filePath);
/// <summary>从 .xp 文件加载 CNC 程序 | Load CNC program from .xp file</summary>
Task<CncProgram> LoadAsync(string filePath);
/// <summary>将 CNC 程序序列化为 JSON 字符串 | Serialize CNC program to JSON string</summary>
string Serialize(CncProgram program);
/// <summary>从 JSON 字符串反序列化 CNC 程序 | Deserialize CNC program from JSON string</summary>
CncProgram Deserialize(string json);
}
}
}
@@ -28,11 +28,11 @@ namespace XplorePlane.Services
var formatted = new FormatConvertedBitmap(bitmapSource, PixelFormats.Gray8, null, 0);
int width = formatted.PixelWidth;
int height = formatted.PixelHeight;
int stride = width;
byte[] pixels = new byte[height * stride];
formatted.CopyPixels(pixels, stride, 0);
var image = new Image<Gray, byte>(width, height);
int stride = image.Bytes.Length / height;
var pixels = new byte[height * stride];
formatted.CopyPixels(pixels, stride, 0);
image.Bytes = pixels;
return image;
}
@@ -40,7 +40,19 @@ namespace XplorePlane.Services
public static Image<Gray, byte> ToEmguCVFromPixels(byte[] pixels, int width, int height)
{
var image = new Image<Gray, byte>(width, height);
image.Bytes = pixels;
int required = image.Bytes.Length;
if (pixels.Length == required)
{
image.Bytes = pixels;
}
else
{
int stride = required / height;
var padded = new byte[required];
for (int row = 0; row < height; row++)
Buffer.BlockCopy(pixels, row * width, padded, row * stride, width);
image.Bytes = padded;
}
return image;
}
@@ -50,8 +62,8 @@ namespace XplorePlane.Services
int width = emguImage.Width;
int height = emguImage.Height;
int stride = width;
byte[] pixels = emguImage.Bytes;
int stride = pixels.Length / height;
return BitmapSource.Create(width, height, 96, 96, PixelFormats.Gray8, null, pixels, stride);
}
@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.InspectionResults
{
public interface IInspectionResultStore
{
Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null);
Task AppendNodeResultAsync(
InspectionNodeResult nodeResult,
IEnumerable<InspectionMetricResult> metrics = null,
PipelineExecutionSnapshot pipelineSnapshot = null,
IEnumerable<InspectionAssetWriteRequest> assets = null);
Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null);
Task<IReadOnlyList<InspectionRunRecord>> QueryRunsAsync(InspectionRunQuery query = null);
Task<InspectionRunDetail> GetRunDetailAsync(Guid runId);
}
}
File diff suppressed because it is too large Load Diff
+154 -95
View File
@@ -3,7 +3,6 @@ using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
@@ -19,21 +18,27 @@ using XplorePlane.Services.Cnc;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// CNC editor ViewModel that manages the node tree, editing operations and file operations.
/// CNC 编辑器 ViewModel,管理 CNC 程序的节点列表、编辑操作和文件操作
/// CNC editor ViewModel that manages the node list, editing operations and file operations
/// </summary>
public class CncEditorViewModel : BindableBase
{
private readonly ICncProgramService _cncProgramService;
private readonly IAppStateService _appStateService;
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
// 当前 CNC 程序 | Current CNC program
private CncProgram _currentProgram;
private ObservableCollection<CncNodeViewModel> _nodes;
private ObservableCollection<CncNodeViewModel> _treeNodes;
private CncNodeViewModel _selectedNode;
private bool _isModified;
private string _programName;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
public CncEditorViewModel(
ICncProgramService cncProgramService,
IAppStateService appStateService,
@@ -41,13 +46,13 @@ namespace XplorePlane.ViewModels.Cnc
ILoggerService logger)
{
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
ArgumentNullException.ThrowIfNull(appStateService);
_appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<CncEditorViewModel>();
_nodes = new ObservableCollection<CncNodeViewModel>();
_treeNodes = new ObservableCollection<CncNodeViewModel>();
// ── 节点插入命令 | Node insertion commands ──
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint));
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage));
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode));
@@ -58,79 +63,117 @@ namespace XplorePlane.ViewModels.Cnc
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay));
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram));
// ── 节点编辑命令 | Node editing commands ──
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
.ObservesProperty(() => SelectedNode);
MoveNodeUpCommand = new DelegateCommand<CncNodeViewModel>(ExecuteMoveNodeUp);
MoveNodeDownCommand = new DelegateCommand<CncNodeViewModel>(ExecuteMoveNodeDown);
// ── 文件操作命令 | File operation commands ──
SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync());
LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync());
NewProgramCommand = new DelegateCommand(ExecuteNewProgram);
ExportCsvCommand = new DelegateCommand(ExecuteExportCsv);
_logger.Info("CncEditorViewModel initialized");
_logger.Info("CncEditorViewModel 已初始化 | CncEditorViewModel initialized");
}
// ── 属性 | Properties ──────────────────────────────────────────
/// <summary>节点列表 | Node list</summary>
public ObservableCollection<CncNodeViewModel> Nodes
{
get => _nodes;
private set => SetProperty(ref _nodes, value);
}
public ObservableCollection<CncNodeViewModel> TreeNodes
{
get => _treeNodes;
private set => SetProperty(ref _treeNodes, value);
set => SetProperty(ref _nodes, value);
}
/// <summary>当前选中的节点 | Currently selected node</summary>
public CncNodeViewModel SelectedNode
{
get => _selectedNode;
set
{
if (SetProperty(ref _selectedNode, value))
{
RaisePropertyChanged(nameof(HasSelection));
}
}
set => SetProperty(ref _selectedNode, value);
}
public bool HasSelection => SelectedNode != null;
/// <summary>程序是否已修改 | Whether the program has been modified</summary>
public bool IsModified
{
get => _isModified;
set => SetProperty(ref _isModified, value);
}
/// <summary>当前程序名称 | Current program name</summary>
public string ProgramName
{
get => _programName;
set => SetProperty(ref _programName, value);
}
// ── 节点插入命令 | Node insertion commands ──────────────────────
/// <summary>插入参考点命令 | Insert reference point command</summary>
public DelegateCommand InsertReferencePointCommand { get; }
/// <summary>插入保存节点(含图像)命令 | Insert save node with image command</summary>
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
/// <summary>插入保存节点命令 | Insert save node command</summary>
public DelegateCommand InsertSaveNodeCommand { get; }
/// <summary>插入保存位置命令 | Insert save position command</summary>
public DelegateCommand InsertSavePositionCommand { get; }
/// <summary>插入检测模块命令 | Insert inspection module command</summary>
public DelegateCommand InsertInspectionModuleCommand { get; }
/// <summary>插入检测标记命令 | Insert inspection marker command</summary>
public DelegateCommand InsertInspectionMarkerCommand { get; }
/// <summary>插入停顿对话框命令 | Insert pause dialog command</summary>
public DelegateCommand InsertPauseDialogCommand { get; }
/// <summary>插入等待延时命令 | Insert wait delay command</summary>
public DelegateCommand InsertWaitDelayCommand { get; }
/// <summary>插入完成程序命令 | Insert complete program command</summary>
public DelegateCommand InsertCompleteProgramCommand { get; }
// ── 节点编辑命令 | Node editing commands ────────────────────────
/// <summary>删除选中节点命令 | Delete selected node command</summary>
public DelegateCommand DeleteNodeCommand { get; }
/// <summary>上移节点命令 | Move node up command</summary>
public DelegateCommand<CncNodeViewModel> MoveNodeUpCommand { get; }
/// <summary>下移节点命令 | Move node down command</summary>
public DelegateCommand<CncNodeViewModel> MoveNodeDownCommand { get; }
// ── 文件操作命令 | File operation commands ──────────────────────
/// <summary>保存程序命令 | Save program command</summary>
public DelegateCommand SaveProgramCommand { get; }
/// <summary>加载程序命令 | Load program command</summary>
public DelegateCommand LoadProgramCommand { get; }
/// <summary>新建程序命令 | New program command</summary>
public DelegateCommand NewProgramCommand { get; }
/// <summary>导出 CSV 命令 | Export CSV command</summary>
public DelegateCommand ExportCsvCommand { get; }
// ── 命令执行方法 | Command execution methods ────────────────────
/// <summary>
/// 插入指定类型的节点到选中节点之后
/// Insert a node of the specified type after the selected node
/// </summary>
private void ExecuteInsertNode(CncNodeType nodeType)
{
if (_currentProgram == null)
{
ExecuteNewProgram();
_logger.Warn("无法插入节点:当前无程序 | Cannot insert node: no current program");
return;
}
try
@@ -140,14 +183,18 @@ namespace XplorePlane.ViewModels.Cnc
_currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, node);
OnProgramEdited();
_logger.Info("Inserted node: Type={NodeType}", nodeType);
_logger.Info("已插入节点 | Inserted node: Type={NodeType}", nodeType);
}
catch (InvalidOperationException ex)
{
_logger.Warn("Node insertion blocked: {Message}", ex.Message);
// 重复插入 CompleteProgram 等业务规则异常 | Business rule exceptions like duplicate CompleteProgram
_logger.Warn("插入节点被阻止 | Node insertion blocked: {Message}", ex.Message);
}
}
/// <summary>
/// 删除选中节点 | Delete the selected node
/// </summary>
private void ExecuteDeleteNode()
{
if (_currentProgram == null || SelectedNode == null)
@@ -157,14 +204,18 @@ namespace XplorePlane.ViewModels.Cnc
{
_currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index);
OnProgramEdited();
_logger.Info("Deleted node at index: {Index}", SelectedNode.Index);
_logger.Info("已删除节点 | Deleted node at index: {Index}", SelectedNode.Index);
}
catch (ArgumentOutOfRangeException ex)
{
_logger.Warn("Delete node failed: {Message}", ex.Message);
_logger.Warn("删除节点失败 | Delete node failed: {Message}", ex.Message);
}
}
/// <summary>
/// 判断是否可以删除节点(至少保留 1 个节点)
/// Determines whether delete is allowed (at least 1 node must remain)
/// </summary>
private bool CanExecuteDeleteNode()
{
return SelectedNode != null
@@ -172,6 +223,9 @@ namespace XplorePlane.ViewModels.Cnc
&& _currentProgram.Nodes.Count > 1;
}
/// <summary>
/// 上移节点 | Move node up
/// </summary>
private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm)
{
if (_currentProgram == null || nodeVm == null || nodeVm.Index <= 0)
@@ -181,13 +235,17 @@ namespace XplorePlane.ViewModels.Cnc
{
_currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index - 1);
OnProgramEdited();
_logger.Info("已上移节点 | Moved node up: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index - 1);
}
catch (ArgumentOutOfRangeException ex)
{
_logger.Warn("Move node up failed: {Message}", ex.Message);
_logger.Warn("上移节点失败 | Move node up failed: {Message}", ex.Message);
}
}
/// <summary>
/// 下移节点 | Move node down
/// </summary>
private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm)
{
if (_currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1)
@@ -197,18 +255,22 @@ namespace XplorePlane.ViewModels.Cnc
{
_currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index + 1);
OnProgramEdited();
_logger.Info("已下移节点 | Moved node down: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index + 1);
}
catch (ArgumentOutOfRangeException ex)
{
_logger.Warn("Move node down failed: {Message}", ex.Message);
_logger.Warn("下移节点失败 | Move node down failed: {Message}", ex.Message);
}
}
/// <summary>
/// 保存当前程序到文件 | Save current program to file
/// </summary>
private async Task ExecuteSaveProgramAsync()
{
if (_currentProgram == null)
{
_logger.Warn("Cannot save: no current program");
_logger.Warn("无法保存:当前无程序 | Cannot save: no current program");
return;
}
@@ -227,13 +289,17 @@ namespace XplorePlane.ViewModels.Cnc
await _cncProgramService.SaveAsync(_currentProgram, dlg.FileName);
IsModified = false;
_logger.Info("程序已保存 | Program saved: {FilePath}", dlg.FileName);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to save program");
_logger.Error(ex, "保存程序失败 | Failed to save program");
}
}
/// <summary>
/// 从文件加载程序 | Load program from file
/// </summary>
private async Task ExecuteLoadProgramAsync()
{
try
@@ -252,27 +318,35 @@ namespace XplorePlane.ViewModels.Cnc
ProgramName = _currentProgram.Name;
IsModified = false;
RefreshNodes();
_logger.Info("程序已加载 | Program loaded: {ProgramName}", _currentProgram.Name);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to load program");
_logger.Error(ex, "加载程序失败 | Failed to load program");
}
}
/// <summary>
/// 创建新程序 | Create a new program
/// </summary>
private void ExecuteNewProgram()
{
var name = string.IsNullOrWhiteSpace(ProgramName) ? "NewCncProgram" : ProgramName;
var name = string.IsNullOrWhiteSpace(ProgramName) ? "新程序" : ProgramName;
_currentProgram = _cncProgramService.CreateProgram(name);
ProgramName = _currentProgram.Name;
IsModified = false;
RefreshNodes();
_logger.Info("已创建新程序 | Created new program: {ProgramName}", name);
}
/// <summary>
/// 导出当前程序为 CSV 文件 | Export current program to CSV file
/// </summary>
private void ExecuteExportCsv()
{
if (_currentProgram == null || _currentProgram.Nodes.Count == 0)
{
_logger.Warn("Cannot export CSV: no program or empty nodes");
_logger.Warn("无法导出 CSV:当前无程序或节点为空 | Cannot export CSV: no program or empty nodes");
return;
}
@@ -290,7 +364,8 @@ namespace XplorePlane.ViewModels.Cnc
return;
var sb = new StringBuilder();
sb.AppendLine("Index,NodeType,Name,XM,YM,ZT,ZD,TiltD,Dist,Voltage_kV,Current_uA,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline");
// CSV 表头 | CSV header
sb.AppendLine("Index,NodeType,Name,XM,YM,ZT,ZD,TiltD,Dist,Voltage_kV,Power_W,RayOn,DetectorConnected,FrameRate,Resolution,ImageFile,MarkerType,MarkerX,MarkerY,DialogTitle,DialogMessage,DelayMs,Pipeline");
var inv = CultureInfo.InvariantCulture;
@@ -298,15 +373,24 @@ namespace XplorePlane.ViewModels.Cnc
{
var row = node switch
{
ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.XM.ToString(inv)},{rp.YM.ToString(inv)},{rp.ZT.ToString(inv)},{rp.ZD.ToString(inv)},{rp.TiltD.ToString(inv)},{rp.Dist.ToString(inv)},{rp.Voltage.ToString(inv)},{rp.Current.ToString(inv)},,{rp.IsRayOn},,,,,,,,,,",
SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.XM.ToString(inv)},{sni.MotionState.YM.ToString(inv)},{sni.MotionState.ZT.ToString(inv)},{sni.MotionState.ZD.ToString(inv)},{sni.MotionState.TiltD.ToString(inv)},{sni.MotionState.Dist.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},,{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,",
SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.XM.ToString(inv)},{sn.MotionState.YM.ToString(inv)},{sn.MotionState.ZT.ToString(inv)},{sn.MotionState.ZD.ToString(inv)},{sn.MotionState.TiltD.ToString(inv)},{sn.MotionState.Dist.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},,{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,",
ReferencePointNode rp => $"{rp.Index},{rp.NodeType},{Esc(rp.Name)},{rp.XM.ToString(inv)},{rp.YM.ToString(inv)},{rp.ZT.ToString(inv)},{rp.ZD.ToString(inv)},{rp.TiltD.ToString(inv)},{rp.Dist.ToString(inv)},,,,,,,,,,,,,,",
SaveNodeWithImageNode sni => $"{sni.Index},{sni.NodeType},{Esc(sni.Name)},{sni.MotionState.XM.ToString(inv)},{sni.MotionState.YM.ToString(inv)},{sni.MotionState.ZT.ToString(inv)},{sni.MotionState.ZD.ToString(inv)},{sni.MotionState.TiltD.ToString(inv)},{sni.MotionState.Dist.ToString(inv)},{sni.RaySourceState.Voltage.ToString(inv)},{sni.RaySourceState.Power.ToString(inv)},{sni.RaySourceState.IsOn},{sni.DetectorState.IsConnected},{sni.DetectorState.FrameRate.ToString(inv)},{Esc(sni.DetectorState.Resolution)},{Esc(sni.ImageFileName)},,,,,,",
SaveNodeNode sn => $"{sn.Index},{sn.NodeType},{Esc(sn.Name)},{sn.MotionState.XM.ToString(inv)},{sn.MotionState.YM.ToString(inv)},{sn.MotionState.ZT.ToString(inv)},{sn.MotionState.ZD.ToString(inv)},{sn.MotionState.TiltD.ToString(inv)},{sn.MotionState.Dist.ToString(inv)},{sn.RaySourceState.Voltage.ToString(inv)},{sn.RaySourceState.Power.ToString(inv)},{sn.RaySourceState.IsOn},{sn.DetectorState.IsConnected},{sn.DetectorState.FrameRate.ToString(inv)},{Esc(sn.DetectorState.Resolution)},,,,,,,",
SavePositionNode sp => $"{sp.Index},{sp.NodeType},{Esc(sp.Name)},{sp.MotionState.XM.ToString(inv)},{sp.MotionState.YM.ToString(inv)},{sp.MotionState.ZT.ToString(inv)},{sp.MotionState.ZD.ToString(inv)},{sp.MotionState.TiltD.ToString(inv)},{sp.MotionState.Dist.ToString(inv)},,,,,,,,,,,,,,",
InspectionModuleNode im => $"{im.Index},{im.NodeType},{Esc(im.Name)},,,,,,,,,,,,,,,,,,,{Esc(im.Pipeline?.Name ?? string.Empty)}",
InspectionMarkerNode mk => $"{mk.Index},{mk.NodeType},{Esc(mk.Name)},,,,,,,,,,,,,,{Esc(mk.MarkerType)},{mk.MarkerX.ToString(inv)},{mk.MarkerY.ToString(inv)},,,",
PauseDialogNode pd => $"{pd.Index},{pd.NodeType},{Esc(pd.Name)},,,,,,,,,,,,,,,,,{Esc(pd.DialogTitle)},{Esc(pd.DialogMessage)},,",
WaitDelayNode wd => $"{wd.Index},{wd.NodeType},{Esc(wd.Name)},,,,,,,,,,,,,,,,,,,{wd.DelayMilliseconds},",
CompleteProgramNode cp => $"{cp.Index},{cp.NodeType},{Esc(cp.Name)},,,,,,,,,,,,,,,,,,,,",
_ => $"{node.Index},{node.NodeType},{Esc(node.Name)},,,,,,,,,,,,,,,,,,,,"
};
@@ -314,13 +398,18 @@ namespace XplorePlane.ViewModels.Cnc
}
File.WriteAllText(dlg.FileName, sb.ToString(), Encoding.UTF8);
_logger.Info("CSV 已导出 | CSV exported: {FilePath}, 节点数={Count}", dlg.FileName, _currentProgram.Nodes.Count);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to export CSV");
_logger.Error(ex, "导出 CSV 失败 | Failed to export CSV");
}
}
/// <summary>
/// CSV 字段转义:含逗号、引号或换行时用双引号包裹
/// Escape CSV field: wrap with double quotes if it contains comma, quote, or newline
/// </summary>
private static string Esc(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
@@ -329,6 +418,12 @@ namespace XplorePlane.ViewModels.Cnc
return value;
}
// ── 辅助方法 | Helper methods ───────────────────────────────────
/// <summary>
/// 编辑操作后的统一处理:刷新节点、标记已修改、发布变更事件
/// Unified post-edit handling: refresh nodes, mark modified, publish change event
/// </summary>
private void OnProgramEdited()
{
IsModified = true;
@@ -336,70 +431,34 @@ namespace XplorePlane.ViewModels.Cnc
PublishProgramChanged();
}
private void HandleNodeModelChanged(CncNodeViewModel nodeVm, CncNode updatedNode)
{
if (_currentProgram == null)
return;
_currentProgram = _cncProgramService.UpdateNode(_currentProgram, nodeVm.Index, updatedNode);
IsModified = true;
ProgramName = _currentProgram.Name;
PublishProgramChanged();
}
/// <summary>
/// 从 _currentProgram.Nodes 重建 Nodes 集合
/// Rebuild the Nodes collection from _currentProgram.Nodes
/// </summary>
private void RefreshNodes()
{
var selectedId = SelectedNode?.Id;
var expansionState = Nodes.ToDictionary(node => node.Id, node => node.IsExpanded);
Nodes.Clear();
var flatNodes = new List<CncNodeViewModel>();
var rootNodes = new List<CncNodeViewModel>();
CncNodeViewModel currentModule = null;
if (_currentProgram?.Nodes == null)
return;
if (_currentProgram?.Nodes != null)
foreach (var node in _currentProgram.Nodes)
{
foreach (var node in _currentProgram.Nodes)
{
var vm = new CncNodeViewModel(node, HandleNodeModelChanged)
{
IsExpanded = expansionState.TryGetValue(node.Id, out var isExpanded) ? isExpanded : true
};
flatNodes.Add(vm);
if (vm.IsInspectionModule)
{
rootNodes.Add(vm);
currentModule = vm;
continue;
}
if (currentModule != null && IsModuleChild(vm.NodeType))
{
currentModule.Children.Add(vm);
continue;
}
rootNodes.Add(vm);
currentModule = null;
}
Nodes.Add(new CncNodeViewModel(node));
}
Nodes = new ObservableCollection<CncNodeViewModel>(flatNodes);
TreeNodes = new ObservableCollection<CncNodeViewModel>(rootNodes);
SelectedNode = selectedId.HasValue
? Nodes.FirstOrDefault(node => node.Id == selectedId.Value) ?? Nodes.LastOrDefault()
: Nodes.LastOrDefault();
}
private static bool IsModuleChild(CncNodeType type)
{
return type is CncNodeType.InspectionMarker
or CncNodeType.PauseDialog
or CncNodeType.WaitDelay;
// 尝试保持选中状态 | Try to preserve selection
if (SelectedNode != null)
{
var match = Nodes.FirstOrDefault(n => n.Index == SelectedNode.Index);
SelectedNode = match ?? Nodes.LastOrDefault();
}
}
/// <summary>
/// 通过 IEventAggregator 发布 CNC 程序变更事件
/// Publish CNC program changed event via IEventAggregator
/// </summary>
private void PublishProgramChanged()
{
_eventAggregator
@@ -407,4 +466,4 @@ namespace XplorePlane.ViewModels.Cnc
.Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified));
}
}
}
}
+55 -509
View File
@@ -1,543 +1,89 @@
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
using XplorePlane.Models;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// CNC node ViewModel with editable properties and tree children.
/// CNC 节点 ViewModel,将 CncNode 模型封装为可绑定的 WPF ViewModel
/// CNC node ViewModel that wraps a CncNode model into a bindable WPF ViewModel
/// </summary>
public class CncNodeViewModel : BindableBase
{
private readonly Action<CncNodeViewModel, CncNode> _modelChangedCallback;
private CncNode _model;
private int _index;
private string _name;
private CncNodeType _nodeType;
private string _icon;
private bool _isExpanded = true;
public CncNodeViewModel(CncNode model, Action<CncNodeViewModel, CncNode> modelChangedCallback)
/// <summary>
/// 构造函数,从 CncNode 模型初始化 ViewModel
/// Constructor that initializes the ViewModel from a CncNode model
/// </summary>
public CncNodeViewModel(CncNode model)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_modelChangedCallback = modelChangedCallback ?? throw new ArgumentNullException(nameof(modelChangedCallback));
Model = model;
_index = model.Index;
_name = model.Name;
_nodeType = model.NodeType;
_icon = GetIconForNodeType(model.NodeType);
Children = new ObservableCollection<CncNodeViewModel>();
}
public ObservableCollection<CncNodeViewModel> Children { get; }
/// <summary>底层 CNC 节点模型(只读)| Underlying CNC node model (read-only)</summary>
public CncNode Model { get; }
public CncNode Model => _model;
public Guid Id => _model.Id;
public int Index => _model.Index;
/// <summary>节点在程序中的索引 | Node index in the program</summary>
public int Index
{
get => _index;
set => SetProperty(ref _index, value);
}
/// <summary>节点显示名称 | Node display name</summary>
public string Name
{
get => _model.Name;
set => UpdateModel(_model with { Name = value ?? string.Empty });
get => _name;
set => SetProperty(ref _name, value);
}
public CncNodeType NodeType => _model.NodeType;
public string NodeTypeDisplay => NodeType.ToString();
/// <summary>节点类型 | Node type</summary>
public CncNodeType NodeType
{
get => _nodeType;
set
{
if (SetProperty(ref _nodeType, value))
{
// 类型变更时自动更新图标 | Auto-update icon when type changes
Icon = GetIconForNodeType(value);
}
}
}
/// <summary>节点图标路径 | Node icon path</summary>
public string Icon
{
get => _icon;
private set => SetProperty(ref _icon, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
public bool HasChildren => Children.Count > 0;
public bool IsReferencePoint => _model is ReferencePointNode;
public bool IsSaveNode => _model is SaveNodeNode;
public bool IsSaveNodeWithImage => _model is SaveNodeWithImageNode;
public bool IsSavePosition => _model is SavePositionNode;
public bool IsInspectionModule => _model is InspectionModuleNode;
public bool IsInspectionMarker => _model is InspectionMarkerNode;
public bool IsPauseDialog => _model is PauseDialogNode;
public bool IsWaitDelay => _model is WaitDelayNode;
public bool IsCompleteProgram => _model is CompleteProgramNode;
public bool IsMotionSnapshotNode => _model is ReferencePointNode or SaveNodeNode or SaveNodeWithImageNode or SavePositionNode;
public double XM
{
get => _model switch
{
ReferencePointNode rp => rp.XM,
SaveNodeNode sn => sn.MotionState.XM,
SaveNodeWithImageNode sni => sni.MotionState.XM,
SavePositionNode sp => sp.MotionState.XM,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.XM);
}
public double YM
{
get => _model switch
{
ReferencePointNode rp => rp.YM,
SaveNodeNode sn => sn.MotionState.YM,
SaveNodeWithImageNode sni => sni.MotionState.YM,
SavePositionNode sp => sp.MotionState.YM,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.YM);
}
public double ZT
{
get => _model switch
{
ReferencePointNode rp => rp.ZT,
SaveNodeNode sn => sn.MotionState.ZT,
SaveNodeWithImageNode sni => sni.MotionState.ZT,
SavePositionNode sp => sp.MotionState.ZT,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.ZT);
}
public double ZD
{
get => _model switch
{
ReferencePointNode rp => rp.ZD,
SaveNodeNode sn => sn.MotionState.ZD,
SaveNodeWithImageNode sni => sni.MotionState.ZD,
SavePositionNode sp => sp.MotionState.ZD,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.ZD);
}
public double TiltD
{
get => _model switch
{
ReferencePointNode rp => rp.TiltD,
SaveNodeNode sn => sn.MotionState.TiltD,
SaveNodeWithImageNode sni => sni.MotionState.TiltD,
SavePositionNode sp => sp.MotionState.TiltD,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.TiltD);
}
public double Dist
{
get => _model switch
{
ReferencePointNode rp => rp.Dist,
SaveNodeNode sn => sn.MotionState.Dist,
SaveNodeWithImageNode sni => sni.MotionState.Dist,
SavePositionNode sp => sp.MotionState.Dist,
_ => 0d
};
set => UpdateMotion(value, MotionAxis.Dist);
}
public bool IsRayOn
{
get => _model switch
{
ReferencePointNode rp => rp.IsRayOn,
SaveNodeNode sn => sn.RaySourceState.IsOn,
SaveNodeWithImageNode sni => sni.RaySourceState.IsOn,
_ => false
};
set => UpdateRaySource(isOn: value);
}
public double Voltage
{
get => _model switch
{
ReferencePointNode rp => rp.Voltage,
SaveNodeNode sn => sn.RaySourceState.Voltage,
SaveNodeWithImageNode sni => sni.RaySourceState.Voltage,
_ => 0d
};
set => UpdateRaySource(voltage: value);
}
public double Current
{
get => _model is ReferencePointNode rp ? rp.Current : 0d;
set
{
if (_model is ReferencePointNode rp)
{
UpdateModel(rp with { Current = value });
}
}
}
public double Power
{
get => _model switch
{
SaveNodeNode sn => sn.RaySourceState.Power,
SaveNodeWithImageNode sni => sni.RaySourceState.Power,
_ => 0d
};
set => UpdateRaySource(power: value);
}
public bool DetectorConnected
{
get => _model switch
{
SaveNodeNode sn => sn.DetectorState.IsConnected,
SaveNodeWithImageNode sni => sni.DetectorState.IsConnected,
_ => false
};
set => UpdateDetector(isConnected: value);
}
public bool DetectorAcquiring
{
get => _model switch
{
SaveNodeNode sn => sn.DetectorState.IsAcquiring,
SaveNodeWithImageNode sni => sni.DetectorState.IsAcquiring,
_ => false
};
set => UpdateDetector(isAcquiring: value);
}
public double FrameRate
{
get => _model switch
{
SaveNodeNode sn => sn.DetectorState.FrameRate,
SaveNodeWithImageNode sni => sni.DetectorState.FrameRate,
_ => 0d
};
set => UpdateDetector(frameRate: value);
}
public string Resolution
{
get => _model switch
{
SaveNodeNode sn => sn.DetectorState.Resolution,
SaveNodeWithImageNode sni => sni.DetectorState.Resolution,
_ => string.Empty
};
set => UpdateDetector(resolution: value ?? string.Empty);
}
public string ImageFileName
{
get => _model is SaveNodeWithImageNode sni ? sni.ImageFileName : string.Empty;
set
{
if (_model is SaveNodeWithImageNode sni)
{
UpdateModel(sni with { ImageFileName = value ?? string.Empty });
}
}
}
public string PipelineName
{
get => _model is InspectionModuleNode im ? im.Pipeline?.Name ?? string.Empty : string.Empty;
set
{
if (_model is InspectionModuleNode im)
{
var pipeline = im.Pipeline ?? new PipelineModel();
pipeline.Name = value ?? string.Empty;
UpdateModel(im with { Pipeline = pipeline });
}
}
}
public PipelineModel Pipeline
{
get => _model is InspectionModuleNode im ? im.Pipeline : null;
set
{
if (_model is InspectionModuleNode im)
{
UpdateModel(im with { Pipeline = value ?? new PipelineModel() });
}
}
}
public string MarkerType
{
get => _model is InspectionMarkerNode mk ? mk.MarkerType : string.Empty;
set
{
if (_model is InspectionMarkerNode mk)
{
UpdateModel(mk with { MarkerType = value ?? string.Empty });
}
}
}
public double MarkerX
{
get => _model is InspectionMarkerNode mk ? mk.MarkerX : 0d;
set
{
if (_model is InspectionMarkerNode mk)
{
UpdateModel(mk with { MarkerX = value });
}
}
}
public double MarkerY
{
get => _model is InspectionMarkerNode mk ? mk.MarkerY : 0d;
set
{
if (_model is InspectionMarkerNode mk)
{
UpdateModel(mk with { MarkerY = value });
}
}
}
public string DialogTitle
{
get => _model is PauseDialogNode pd ? pd.DialogTitle : string.Empty;
set
{
if (_model is PauseDialogNode pd)
{
UpdateModel(pd with { DialogTitle = value ?? string.Empty });
}
}
}
public string DialogMessage
{
get => _model is PauseDialogNode pd ? pd.DialogMessage : string.Empty;
set
{
if (_model is PauseDialogNode pd)
{
UpdateModel(pd with { DialogMessage = value ?? string.Empty });
}
}
}
public int DelayMilliseconds
{
get => _model is WaitDelayNode wd ? wd.DelayMilliseconds : 0;
set
{
if (_model is WaitDelayNode wd)
{
UpdateModel(wd with { DelayMilliseconds = value });
}
}
}
public void ReplaceModel(CncNode model)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
Icon = GetIconForNodeType(model.NodeType);
RaiseAllPropertiesChanged();
set => SetProperty(ref _icon, value);
}
/// <summary>
/// 根据节点类型返回对应的图标路径
/// Returns the icon path for the given node type
/// </summary>
public static string GetIconForNodeType(CncNodeType nodeType)
{
return nodeType switch
{
CncNodeType.ReferencePoint => "/Assets/Icons/reference.png",
CncNodeType.SaveNodeWithImage => "/Assets/Icons/saveall.png",
CncNodeType.SaveNode => "/Assets/Icons/save.png",
CncNodeType.SavePosition => "/Assets/Icons/add-pos.png",
CncNodeType.InspectionModule => "/Assets/Icons/Module.png",
CncNodeType.InspectionMarker => "/Assets/Icons/mark.png",
CncNodeType.PauseDialog => "/Assets/Icons/message.png",
CncNodeType.WaitDelay => "/Assets/Icons/wait.png",
CncNodeType.CompleteProgram => "/Assets/Icons/finish.png",
_ => "/Assets/Icons/cnc.png",
CncNodeType.ReferencePoint => "/Resources/Icons/cnc_reference_point.png",
CncNodeType.SaveNodeWithImage => "/Resources/Icons/cnc_save_with_image.png",
CncNodeType.SaveNode => "/Resources/Icons/cnc_save_node.png",
CncNodeType.SavePosition => "/Resources/Icons/cnc_save_position.png",
CncNodeType.InspectionModule => "/Resources/Icons/cnc_inspection_module.png",
CncNodeType.InspectionMarker => "/Resources/Icons/cnc_inspection_marker.png",
CncNodeType.PauseDialog => "/Resources/Icons/cnc_pause_dialog.png",
CncNodeType.WaitDelay => "/Resources/Icons/cnc_wait_delay.png",
CncNodeType.CompleteProgram => "/Resources/Icons/cnc_complete_program.png",
_ => "/Resources/Icons/cnc_default.png",
};
}
private void UpdateMotion(double value, MotionAxis axis)
{
switch (_model)
{
case ReferencePointNode rp:
UpdateModel(axis switch
{
MotionAxis.XM => rp with { XM = value },
MotionAxis.YM => rp with { YM = value },
MotionAxis.ZT => rp with { ZT = value },
MotionAxis.ZD => rp with { ZD = value },
MotionAxis.TiltD => rp with { TiltD = value },
MotionAxis.Dist => rp with { Dist = value },
_ => rp
});
break;
case SaveNodeNode sn:
UpdateModel(sn with { MotionState = UpdateMotionState(sn.MotionState, axis, value) });
break;
case SaveNodeWithImageNode sni:
UpdateModel(sni with { MotionState = UpdateMotionState(sni.MotionState, axis, value) });
break;
case SavePositionNode sp:
UpdateModel(sp with { MotionState = UpdateMotionState(sp.MotionState, axis, value) });
break;
}
}
private void UpdateRaySource(bool? isOn = null, double? voltage = null, double? current = null, double? power = null)
{
switch (_model)
{
case ReferencePointNode rp:
UpdateModel(rp with
{
IsRayOn = isOn ?? rp.IsRayOn,
Voltage = voltage ?? rp.Voltage,
Current = current ?? rp.Current
});
break;
case SaveNodeNode sn:
UpdateModel(sn with
{
RaySourceState = sn.RaySourceState with
{
IsOn = isOn ?? sn.RaySourceState.IsOn,
Voltage = voltage ?? sn.RaySourceState.Voltage,
Power = power ?? sn.RaySourceState.Power
}
});
break;
case SaveNodeWithImageNode sni:
UpdateModel(sni with
{
RaySourceState = sni.RaySourceState with
{
IsOn = isOn ?? sni.RaySourceState.IsOn,
Voltage = voltage ?? sni.RaySourceState.Voltage,
Power = power ?? sni.RaySourceState.Power
}
});
break;
}
}
private void UpdateDetector(bool? isConnected = null, bool? isAcquiring = null, double? frameRate = null, string resolution = null)
{
switch (_model)
{
case SaveNodeNode sn:
UpdateModel(sn with
{
DetectorState = sn.DetectorState with
{
IsConnected = isConnected ?? sn.DetectorState.IsConnected,
IsAcquiring = isAcquiring ?? sn.DetectorState.IsAcquiring,
FrameRate = frameRate ?? sn.DetectorState.FrameRate,
Resolution = resolution ?? sn.DetectorState.Resolution
}
});
break;
case SaveNodeWithImageNode sni:
UpdateModel(sni with
{
DetectorState = sni.DetectorState with
{
IsConnected = isConnected ?? sni.DetectorState.IsConnected,
IsAcquiring = isAcquiring ?? sni.DetectorState.IsAcquiring,
FrameRate = frameRate ?? sni.DetectorState.FrameRate,
Resolution = resolution ?? sni.DetectorState.Resolution
}
});
break;
}
}
private static MotionState UpdateMotionState(MotionState state, MotionAxis axis, double value)
{
return axis switch
{
MotionAxis.XM => state with { XM = value },
MotionAxis.YM => state with { YM = value },
MotionAxis.ZT => state with { ZT = value },
MotionAxis.ZD => state with { ZD = value },
MotionAxis.TiltD => state with { TiltD = value },
MotionAxis.Dist => state with { Dist = value },
_ => state
};
}
private void UpdateModel(CncNode updatedModel)
{
_model = updatedModel ?? throw new ArgumentNullException(nameof(updatedModel));
RaiseAllPropertiesChanged();
_modelChangedCallback(this, updatedModel);
}
private void RaiseAllPropertiesChanged()
{
RaisePropertyChanged(nameof(Model));
RaisePropertyChanged(nameof(Id));
RaisePropertyChanged(nameof(Index));
RaisePropertyChanged(nameof(Name));
RaisePropertyChanged(nameof(NodeType));
RaisePropertyChanged(nameof(NodeTypeDisplay));
RaisePropertyChanged(nameof(Icon));
RaisePropertyChanged(nameof(IsReferencePoint));
RaisePropertyChanged(nameof(IsSaveNode));
RaisePropertyChanged(nameof(IsSaveNodeWithImage));
RaisePropertyChanged(nameof(IsSavePosition));
RaisePropertyChanged(nameof(IsInspectionModule));
RaisePropertyChanged(nameof(IsInspectionMarker));
RaisePropertyChanged(nameof(IsPauseDialog));
RaisePropertyChanged(nameof(IsWaitDelay));
RaisePropertyChanged(nameof(IsCompleteProgram));
RaisePropertyChanged(nameof(IsMotionSnapshotNode));
RaisePropertyChanged(nameof(XM));
RaisePropertyChanged(nameof(YM));
RaisePropertyChanged(nameof(ZT));
RaisePropertyChanged(nameof(ZD));
RaisePropertyChanged(nameof(TiltD));
RaisePropertyChanged(nameof(Dist));
RaisePropertyChanged(nameof(IsRayOn));
RaisePropertyChanged(nameof(Voltage));
RaisePropertyChanged(nameof(Current));
RaisePropertyChanged(nameof(Power));
RaisePropertyChanged(nameof(DetectorConnected));
RaisePropertyChanged(nameof(DetectorAcquiring));
RaisePropertyChanged(nameof(FrameRate));
RaisePropertyChanged(nameof(Resolution));
RaisePropertyChanged(nameof(ImageFileName));
RaisePropertyChanged(nameof(Pipeline));
RaisePropertyChanged(nameof(PipelineName));
RaisePropertyChanged(nameof(MarkerType));
RaisePropertyChanged(nameof(MarkerX));
RaisePropertyChanged(nameof(MarkerY));
RaisePropertyChanged(nameof(DialogTitle));
RaisePropertyChanged(nameof(DialogMessage));
RaisePropertyChanged(nameof(DelayMilliseconds));
}
private enum MotionAxis
{
XM,
YM,
ZT,
ZD,
TiltD,
Dist
}
}
}
}
@@ -8,7 +8,6 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
@@ -17,7 +16,7 @@ using XplorePlane.Services;
namespace XplorePlane.ViewModels
{
public class PipelineEditorViewModel : BindableBase, IPipelineEditorHostViewModel
public class PipelineEditorViewModel : BindableBase
{
private const int MaxPipelineLength = 20;
private const int DebounceDelayMs = 300;
@@ -166,15 +165,6 @@ namespace XplorePlane.ViewModels
public DelegateCommand<PipelineNodeViewModel> MoveNodeUpCommand { get; }
public DelegateCommand<PipelineNodeViewModel> MoveNodeDownCommand { get; }
ICommand IPipelineEditorHostViewModel.AddOperatorCommand => AddOperatorCommand;
ICommand IPipelineEditorHostViewModel.RemoveOperatorCommand => RemoveOperatorCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeUpCommand => MoveNodeUpCommand;
ICommand IPipelineEditorHostViewModel.MoveNodeDownCommand => MoveNodeDownCommand;
ICommand IPipelineEditorHostViewModel.NewPipelineCommand => NewPipelineCommand;
ICommand IPipelineEditorHostViewModel.SavePipelineCommand => SavePipelineCommand;
ICommand IPipelineEditorHostViewModel.SaveAsPipelineCommand => SaveAsPipelineCommand;
ICommand IPipelineEditorHostViewModel.LoadPipelineCommand => LoadPipelineCommand;
// ── Command Implementations ───────────────────────────────────
private bool CanAddOperator(string operatorKey) =>
+2 -93
View File
@@ -1,4 +1,4 @@
using Prism.Commands;
using Prism.Commands;
using Prism.Events;
using Prism.Ioc;
using Prism.Mvvm;
@@ -10,9 +10,6 @@ using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using XplorePlane.Events;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.Views;
using XplorePlane.Views.Cnc;
using XP.Common.Logging.Interfaces;
using XP.Common.PdfViewer.Interfaces;
using XP.Hardware.MotionControl.Abstractions;
@@ -21,12 +18,9 @@ namespace XplorePlane.ViewModels
{
public class MainViewModel : BindableBase
{
private const double CncEditorHostWidth = 710d;
private readonly ILoggerService _logger;
private readonly IContainerProvider _containerProvider;
private readonly IEventAggregator _eventAggregator;
private readonly CncEditorViewModel _cncEditorViewModel;
private readonly CncPageView _cncPageView;
private string _licenseInfo = "当前时间";
public string LicenseInfo
@@ -55,17 +49,6 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenLibraryVersionsCommand { get; }
public DelegateCommand OpenUserManualCommand { get; }
public DelegateCommand OpenCameraSettingsCommand { get; }
public DelegateCommand NewCncProgramCommand { get; }
public DelegateCommand SaveCncProgramCommand { get; }
public DelegateCommand LoadCncProgramCommand { get; }
public DelegateCommand InsertReferencePointCommand { get; }
public DelegateCommand InsertSavePositionCommand { get; }
public DelegateCommand InsertCompleteProgramCommand { get; }
public DelegateCommand InsertInspectionMarkerCommand { get; }
public DelegateCommand InsertInspectionModuleCommand { get; }
public DelegateCommand InsertSaveNodeCommand { get; }
public DelegateCommand InsertPauseDialogCommand { get; }
public DelegateCommand InsertWaitDelayCommand { get; }
// 硬件命令
public DelegateCommand AxisResetCommand { get; }
@@ -79,27 +62,6 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenLanguageSwitcherCommand { get; }
public DelegateCommand OpenRealTimeLogViewerCommand { get; }
/// <summary>右侧图像区域内容 | Right-side image panel content</summary>
public object ImagePanelContent
{
get => _imagePanelContent;
set => SetProperty(ref _imagePanelContent, value);
}
/// <summary>右侧图像区域宽度 | Right-side image panel width</summary>
public GridLength ImagePanelWidth
{
get => _imagePanelWidth;
set => SetProperty(ref _imagePanelWidth, value);
}
/// <summary>主视图区宽度 | Main viewport width</summary>
public GridLength ViewportPanelWidth
{
get => _viewportPanelWidth;
set => SetProperty(ref _viewportPanelWidth, value);
}
// 窗口引用(单例窗口防止重复打开)
private Window _motionDebugWindow;
private Window _detectorConfigWindow;
@@ -107,18 +69,12 @@ namespace XplorePlane.ViewModels
private Window _realTimeLogViewerWindow;
private Window _toolboxWindow;
private Window _raySourceConfigWindow;
private object _imagePanelContent;
private GridLength _viewportPanelWidth = new GridLength(1, GridUnitType.Star);
private GridLength _imagePanelWidth = new GridLength(320);
private bool _isCncEditorMode;
public MainViewModel(ILoggerService logger, IContainerProvider containerProvider, IEventAggregator eventAggregator)
{
_logger = logger?.ForModule<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_containerProvider = containerProvider ?? throw new ArgumentNullException(nameof(containerProvider));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
NavigationTree = new ObservableCollection<object>();
@@ -134,23 +90,12 @@ namespace XplorePlane.ViewModels
OpenImageProcessingCommand = new DelegateCommand(() => ShowWindow(new Views.ImageProcessingWindow(), "图像处理"));
LoadImageCommand = new DelegateCommand(ExecuteLoadImage);
OpenPipelineEditorCommand = new DelegateCommand(() => ShowWindow(new Views.PipelineEditorWindow(), "流水线编辑器"));
OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor);
OpenCncEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.CncEditorWindow(), "CNC 编辑器"));
OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编排"));
OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox);
OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于"));
OpenUserManualCommand = new DelegateCommand(ExecuteOpenUserManual);
OpenCameraSettingsCommand = new DelegateCommand(ExecuteOpenCameraSettings);
NewCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.NewProgramCommand.Execute()));
SaveCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.SaveProgramCommand.Execute()));
LoadCncProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.LoadProgramCommand.Execute()));
InsertReferencePointCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertReferencePointCommand.Execute()));
InsertSavePositionCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSavePositionCommand.Execute()));
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertCompleteProgramCommand.Execute()));
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionMarkerCommand.Execute()));
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertInspectionModuleCommand.Execute()));
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertSaveNodeCommand.Execute()));
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertPauseDialogCommand.Execute()));
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteCncEditorAction(vm => vm.InsertWaitDelayCommand.Execute()));
// 硬件命令
AxisResetCommand = new DelegateCommand(ExecuteAxisReset);
@@ -164,10 +109,6 @@ namespace XplorePlane.ViewModels
OpenLanguageSwitcherCommand = new DelegateCommand(ExecuteOpenLanguageSwitcher);
OpenRealTimeLogViewerCommand = new DelegateCommand(ExecuteOpenRealTimeLogViewer);
ImagePanelContent = new PipelineEditorView();
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(320);
_logger.Info("MainViewModel 已初始化");
}
@@ -212,38 +153,6 @@ namespace XplorePlane.ViewModels
ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "算子工具箱");
}
private void ExecuteOpenCncEditor()
{
if (_isCncEditorMode)
{
ImagePanelContent = new PipelineEditorView();
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(320);
_isCncEditorMode = false;
_logger.Info("已退出 CNC 编辑模式");
return;
}
ShowCncEditor();
}
private void ExecuteCncEditorAction(Action<CncEditorViewModel> action)
{
ArgumentNullException.ThrowIfNull(action);
ShowCncEditor();
action(_cncEditorViewModel);
}
private void ShowCncEditor()
{
ImagePanelContent = _cncPageView;
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
ImagePanelWidth = new GridLength(CncEditorHostWidth);
_isCncEditorMode = true;
_logger.Info("CNC 编辑器已切换到主界面图像区域");
}
private void ExecuteOpenUserManual()
{
try
@@ -176,6 +176,30 @@ namespace XplorePlane.ViewModels
ApplyPixelFormatCommand = new DelegateCommand(() => ApplyCameraParam(() => _camera.SetPixelFormat(SelectedPixelFormat)), () => IsCameraConnected);
RefreshCameraParamsCommand = new DelegateCommand(RefreshCameraParams, () => IsCameraConnected);
OpenCameraSettingsCommand = new DelegateCommand(OpenCameraSettings, () => IsCameraConnected);
CameraStatusText = "正在检索相机...";
}
/// <summary>
/// 相机连接完成后由外部调用,启动实时预览。
/// </summary>
public void OnCameraReady()
{
if (!_camera.IsConnected)
{
CameraStatusText = "未检测到相机";
return;
}
_camera.ImageGrabbed += OnCameraImageGrabbed;
_camera.GrabError += OnCameraGrabError;
_camera.ConnectionLost += OnCameraConnectionLost;
IsCameraConnected = true;
CameraStatusText = "已连接";
RefreshCameraParams();
StartGrab();
IsLiveViewEnabled = true;
}
#region Camera Methods
@@ -326,15 +350,18 @@ namespace XplorePlane.ViewModels
private void OnCameraImageGrabbed(object? sender, ImageGrabbedEventArgs e)
{
if (_disposed) return;
try
{
var bitmap = PixelConverter.ToBitmapSource(e.PixelData, e.Width, e.Height, e.PixelFormat);
var app = Application.Current;
if (app == null) return;
app.Dispatcher.Invoke(() =>
app.Dispatcher.BeginInvoke(() =>
{
CameraImageSource = bitmap;
if (!_disposed)
CameraImageSource = bitmap;
});
if (_liveViewRunning)
@@ -344,7 +371,8 @@ namespace XplorePlane.ViewModels
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to process camera image");
if (!_disposed)
_logger.Error(ex, "Failed to process camera image");
}
}
@@ -354,9 +382,10 @@ namespace XplorePlane.ViewModels
var app = Application.Current;
if (app == null) return;
app.Dispatcher.Invoke(() =>
app.Dispatcher.BeginInvoke(() =>
{
CameraStatusText = $"采集错误: {e.ErrorDescription}";
if (!_disposed)
CameraStatusText = $"采集错误: {e.ErrorDescription}";
});
}
@@ -366,8 +395,9 @@ namespace XplorePlane.ViewModels
var app = Application.Current;
if (app == null) return;
app.Dispatcher.Invoke(() =>
app.Dispatcher.BeginInvoke(() =>
{
if (_disposed) return;
IsCameraConnected = false;
IsCameraGrabbing = false;
CameraStatusText = "连接已断开";
@@ -382,12 +412,19 @@ namespace XplorePlane.ViewModels
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_liveViewRunning = false;
try { _camera.Dispose(); }
catch (Exception ex) { _logger.Error(ex, "Error disposing camera"); }
// 先取消事件订阅,防止回调继续触发
_camera.ImageGrabbed -= OnCameraImageGrabbed;
_camera.GrabError -= OnCameraGrabError;
_camera.ConnectionLost -= OnCameraConnectionLost;
_disposed = true;
// 停止采集后再关闭连接
try { if (_camera.IsGrabbing) _camera.StopGrabbing(); } catch { }
try { _camera.Close(); } catch { }
_logger.Information("NavigationPropertyPanelViewModel disposed");
}
#endregion IDisposable
+4 -7
View File
@@ -1,15 +1,12 @@
<Window
<Window
x:Class="XplorePlane.Views.Cnc.CncEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cnc="clr-namespace:XplorePlane.Views.Cnc"
Title="CNC 编辑器"
Width="1040"
Height="780"
MinWidth="960"
MinHeight="720"
ResizeMode="CanResize"
Width="350"
Height="750"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner">
<cnc:CncPageView />
</Window>
</Window>
+197 -357
View File
@@ -1,457 +1,297 @@
<!-- CNC 编辑器主页面视图 | CNC editor main page view -->
<UserControl
x:Class="XplorePlane.Views.Cnc.CncPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:XplorePlane.Views.Cnc"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:prism="http://prismlibrary.com/"
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
d:DesignHeight="760"
d:DesignWidth="702"
d:DesignHeight="700"
d:DesignWidth="350"
prism:ViewModelLocator.AutoWireViewModel="True"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<local:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<!-- 面板背景和边框颜色 | Panel background and border colors -->
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<SolidColorBrush x:Key="SeparatorBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="HeaderBg" Color="#F7F7F7" />
<FontFamily x:Key="UiFont">Microsoft YaHei UI</FontFamily>
<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="TreeItemStyle" TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<!-- 节点列表项样式 | Node list item style -->
<Style x:Key="CncNodeItemStyle" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
<Style x:Key="EditorTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,8" />
</Style>
<Style x:Key="LabelStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="#666666" />
<Setter Property="Margin" Value="0,0,0,3" />
</Style>
<Style x:Key="EditorBox" TargetType="TextBox">
<!-- 工具栏按钮样式 | Toolbar button style -->
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Height" Value="28" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="BorderBrush" Value="#CFCFCF" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="Padding" Value="6,0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="EditorCheck" TargetType="CheckBox">
<Setter Property="Margin" Value="0,2,0,8" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style x:Key="CompactGroupBox" TargetType="GroupBox">
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontFamily" Value="{StaticResource UiFont}" />
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</UserControl.Resources>
<Border
Width="702"
MinWidth="702"
HorizontalAlignment="Left"
Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
<ColumnDefinition Width="1" />
<ColumnDefinition Width="250" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<!-- Row 0: 工具栏 | Toolbar -->
<RowDefinition Height="Auto" />
<!-- Row 1: 主内容区(左侧节点列表 + 右侧参数面板)| Main content (left: node list, right: parameter panel) -->
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- ═══ 工具栏:节点插入命令 + 文件操作命令 | Toolbar: node insert commands + file operation commands ═══ -->
<Border
Grid.Row="0"
Padding="6,4"
Background="#F5F5F5"
BorderBrush="{StaticResource PanelBorder}"
BorderThickness="0,0,0,1">
<WrapPanel Orientation="Horizontal">
<!-- 文件操作按钮 | File operation buttons -->
<Button
Command="{Binding NewProgramCommand}"
Content="新建"
Style="{StaticResource ToolbarBtn}"
ToolTip="新建程序 | New Program" />
<Button
Command="{Binding SaveProgramCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存程序 | Save Program" />
<Button
Command="{Binding LoadProgramCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载程序 | Load Program" />
<Button
Command="{Binding ExportCsvCommand}"
Content="导出CSV"
Style="{StaticResource ToolbarBtn}"
ToolTip="导出 CSV | Export CSV" />
<Border
Grid.Row="0"
Padding="10,8"
Background="{StaticResource HeaderBg}"
BorderBrush="{StaticResource SeparatorBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="{Binding ProgramName, TargetNullValue=CNC编辑}"
TextWrapping="Wrap" />
<TextBlock
Margin="0,3,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="模块节点下会自动显示标记、等待、消息等子节点。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- 分隔线 | Separator -->
<Rectangle
Width="1"
Height="20"
Margin="4,0"
Fill="{StaticResource SeparatorBrush}" />
<TreeView
x:Name="CncTreeView"
Grid.Row="1"
Padding="4,6"
<!-- 节点插入按钮(9 种节点类型)| Node insert buttons (9 node types) -->
<Button
Command="{Binding InsertReferencePointCommand}"
Content="参考点"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入参考点 | Insert Reference Point" />
<Button
Command="{Binding InsertSaveNodeWithImageCommand}"
Content="保存+图"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存节点并保存图片 | Save Node With Image" />
<Button
Command="{Binding InsertSaveNodeCommand}"
Content="保存节点"
Style="{StaticResource ToolbarBtn}"
ToolTip="仅保存节点 | Save Node" />
<Button
Command="{Binding InsertSavePositionCommand}"
Content="保存位置"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存位置 | Save Position" />
<Button
Command="{Binding InsertInspectionModuleCommand}"
Content="检测模块"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入检测模块 | Insert Inspection Module" />
<Button
Command="{Binding InsertInspectionMarkerCommand}"
Content="检测标记"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入检测标记 | Insert Inspection Marker" />
<Button
Command="{Binding InsertPauseDialogCommand}"
Content="停顿"
Style="{StaticResource ToolbarBtn}"
ToolTip="插入停顿对话框 | Insert Pause Dialog" />
<Button
Command="{Binding InsertWaitDelayCommand}"
Content="延时"
Style="{StaticResource ToolbarBtn}"
ToolTip="设置等待延时 | Insert Wait Delay" />
<Button
Command="{Binding InsertCompleteProgramCommand}"
Content="完成"
Style="{StaticResource ToolbarBtn}"
ToolTip="完成程序 | Complete Program" />
</WrapPanel>
</Border>
<!-- ═══ 主内容区:左侧节点列表 + 右侧参数面板 | Main content: left node list + right parameter panel ═══ -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<!-- 左侧:节点列表 | Left: node list -->
<ColumnDefinition Width="3*" MinWidth="150" />
<!-- 分隔线 | Splitter -->
<ColumnDefinition Width="Auto" />
<!-- 右侧:参数面板 | Right: parameter panel -->
<ColumnDefinition Width="2*" MinWidth="150" />
</Grid.ColumnDefinitions>
<!-- ── 左侧:CNC 节点列表 | Left: CNC node list ── -->
<ListBox
x:Name="CncNodeListBox"
Grid.Column="0"
Background="Transparent"
BorderThickness="0"
ItemsSource="{Binding TreeNodes}"
SelectedItemChanged="CncTreeView_SelectedItemChanged">
<TreeView.Resources>
<HierarchicalDataTemplate DataType="{x:Type vm:CncNodeViewModel}" ItemsSource="{Binding Children}">
<Grid x:Name="NodeRoot" MinHeight="34">
ItemContainerStyle="{StaticResource CncNodeItemStyle}"
ItemsSource="{Binding Nodes}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectedItem="{Binding SelectedNode, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid x:Name="NodeRoot" MinHeight="40">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="30" />
<!-- 图标列 | Icon column -->
<ColumnDefinition Width="40" />
<!-- 名称列 | Name column -->
<ColumnDefinition Width="*" />
<!-- 操作按钮列 | Action buttons column -->
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- 节点图标 | Node icon -->
<Border
Grid.Column="0"
Width="22"
Height="22"
Width="28"
Height="28"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="Transparent"
Background="#E8F0FE"
BorderBrush="#5B9BD5"
BorderThickness="1.5"
CornerRadius="4">
<Image
Width="15"
Height="15"
Width="20"
Height="20"
Source="{Binding Icon}"
Stretch="Uniform" />
</Border>
<!-- 节点序号和名称 | Node index and name -->
<StackPanel
Grid.Column="1"
Margin="4,0,0,0"
Margin="6,0,0,0"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="10.5"
Foreground="#888888"
FontFamily="Microsoft YaHei UI"
FontSize="11"
Foreground="#888"
Text="{Binding Index, StringFormat='[{0}] '}" />
<TextBlock
VerticalAlignment="Center"
FontFamily="{StaticResource UiFont}"
FontSize="11.5"
FontFamily="Microsoft YaHei UI"
FontSize="12"
Text="{Binding Name}" />
</StackPanel>
<!-- 悬停操作按钮:上移 / 下移 / 删除 | Hover actions: MoveUp / MoveDown / Delete -->
<StackPanel
x:Name="NodeActions"
Grid.Column="2"
Margin="0,0,2,0"
Margin="0,0,4,0"
VerticalAlignment="Center"
Orientation="Horizontal"
Visibility="Collapsed">
<Button
Width="20"
Height="20"
Width="22"
Height="22"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
ToolTip="上移 | Move Up" />
<Button
Width="20"
Height="20"
Width="22"
Height="22"
Margin="1,0"
Background="White"
BorderBrush="#CDCBCB"
Background="Transparent"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
ToolTip="下移 | Move Down" />
<Button
Width="20"
Height="20"
Width="22"
Height="22"
Margin="1,0"
Background="White"
Background="Transparent"
BorderBrush="#E05050"
BorderThickness="1"
Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=TreeView}}"
Content=""
Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="删除" />
ToolTip="删除 | Delete" />
</StackPanel>
</Grid>
<DataTemplate.Triggers>
<!-- 鼠标悬停时显示操作按钮 | Show action buttons on mouse hover -->
<Trigger SourceName="NodeRoot" Property="IsMouseOver" Value="True">
<Setter TargetName="NodeActions" Property="Visibility" Value="Visible" />
</Trigger>
</DataTemplate.Triggers>
</HierarchicalDataTemplate>
</TreeView.Resources>
<TreeView.ItemContainerStyle>
<Style BasedOn="{StaticResource TreeItemStyle}" TargetType="TreeViewItem" />
</TreeView.ItemContainerStyle>
</TreeView>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- 垂直分隔线 | Vertical separator -->
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<StackPanel Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}}">
<TextBlock Style="{StaticResource EditorTitle}" Text="节点属性" />
<TextBlock Style="{StaticResource LabelStyle}" Text="名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Name, UpdateSourceTrigger=PropertyChanged}" />
<UniformGrid Columns="2" Margin="0,0,0,8">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="索引" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.Index, Mode=OneWay}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="类型" />
<TextBox Style="{StaticResource EditorBox}" IsReadOnly="True" Text="{Binding SelectedNode.NodeTypeDisplay, Mode=OneWay}" />
</StackPanel>
</UniformGrid>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="运动参数"
Visibility="{Binding SelectedNode.IsMotionSnapshotNode, Converter={StaticResource BoolToVisibilityConverter}}">
<UniformGrid Margin="8,8,8,6" Columns="2">
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="XM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.XM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="YM" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.YM, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="ZT" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZT, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="ZD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ZD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel Margin="0,0,6,0">
<TextBlock Style="{StaticResource LabelStyle}" Text="TiltD" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.TiltD, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
<StackPanel>
<TextBlock Style="{StaticResource LabelStyle}" Text="Dist" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Dist, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</UniformGrid>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="射线源"
Visibility="{Binding SelectedNode.IsReferencePoint, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电流 (uA)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Current, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNode, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="保存参数"
Visibility="{Binding SelectedNode.IsSaveNodeWithImage, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<CheckBox Style="{StaticResource EditorCheck}" Content="射线源开启" IsChecked="{Binding SelectedNode.IsRayOn}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="电压 (kV)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Voltage, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="功率 (W)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Power, UpdateSourceTrigger=LostFocus}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器已连接" IsChecked="{Binding SelectedNode.DetectorConnected}" />
<CheckBox Style="{StaticResource EditorCheck}" Content="探测器正在采集" IsChecked="{Binding SelectedNode.DetectorAcquiring}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="帧率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.FrameRate, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="分辨率" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.Resolution, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="图像文件" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.ImageFileName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="检测模块"
Visibility="{Binding SelectedNode.IsInspectionModule, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="Pipeline 名称" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.PipelineName, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="标记参数"
Visibility="{Binding SelectedNode.IsInspectionMarker, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标记类型" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerType, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerX" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerX, UpdateSourceTrigger=LostFocus}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="MarkerY" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.MarkerY, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="消息弹窗"
Visibility="{Binding SelectedNode.IsPauseDialog, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="8,8,8,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="标题" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DialogTitle, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="消息内容" />
<TextBox
MinHeight="68"
Margin="0,0,0,8"
Padding="8,6"
AcceptsReturn="True"
BorderBrush="#CFCFCF"
BorderThickness="1"
FontFamily="{StaticResource UiFont}"
FontSize="11"
Text="{Binding SelectedNode.DialogMessage, UpdateSourceTrigger=PropertyChanged}"
TextWrapping="Wrap" />
</StackPanel>
</GroupBox>
<GroupBox
Style="{StaticResource CompactGroupBox}"
Header="等待参数"
Visibility="{Binding SelectedNode.IsWaitDelay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Margin="10,8,10,6">
<TextBlock Style="{StaticResource LabelStyle}" Text="延时 (ms)" />
<TextBox Style="{StaticResource EditorBox}" Text="{Binding SelectedNode.DelayMilliseconds, UpdateSourceTrigger=LostFocus}" />
</StackPanel>
</GroupBox>
</StackPanel>
<Border
Padding="12"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding SelectedNode, Converter={StaticResource NullToVisibilityConverter}, ConverterParameter=Invert}">
<StackPanel>
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择节点"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="从左侧树中选择一个节点后,这里会显示可编辑的参数。"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
<Rectangle
Grid.Column="3"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<Grid Grid.Column="4">
<views:PipelineEditorView
x:Name="InspectionModulePipelineEditor"
Visibility="{Binding EditorVisibility}" />
<Border
x:Name="InspectionModulePipelineEmptyState"
Margin="12"
Padding="16"
Background="#FAFAFA"
BorderBrush="#E6E6E6"
BorderThickness="1"
CornerRadius="6"
Visibility="{Binding EmptyStateVisibility}">
<StackPanel>
<!-- ── 右侧:参数面板(根据节点类型动态渲染)| Right: parameter panel (dynamic rendering by node type) ── -->
<ScrollViewer
Grid.Column="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,6">
<TextBlock
FontFamily="{StaticResource UiFont}"
FontSize="13"
FontWeight="SemiBold"
Text="未选择检测模块"
TextWrapping="Wrap" />
<TextBlock
Margin="0,6,0,0"
FontFamily="{StaticResource UiFont}"
FontSize="10"
Foreground="#666666"
Text="请选择一个检测模块节点后,在这里拖拽算子并配置参数。"
TextWrapping="Wrap" />
Margin="0,0,0,4"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="参数配置" />
<!-- 动态参数内容区域(占位:根据 SelectedNode 类型渲染)| Dynamic parameter content area (placeholder for node-type-based rendering) -->
<ContentControl Content="{Binding SelectedNode}" />
</StackPanel>
</Border>
</ScrollViewer>
</Grid>
</Grid>
</Border>
</UserControl>
</UserControl>
+3 -81
View File
@@ -1,94 +1,16 @@
using Prism.Ioc;
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using XP.Common.Logging.Interfaces;
using XplorePlane.Services;
using XplorePlane.ViewModels.Cnc;
namespace XplorePlane.Views.Cnc
{
/// <summary>
/// CNC editor main page view.
/// CNC 编辑器主页面视图(MVVM 模式,逻辑在 ViewModel 中)
/// CNC editor main page view (MVVM pattern, logic in ViewModel)
/// </summary>
public partial class CncPageView : UserControl
{
private CncInspectionModulePipelineViewModel _inspectionModulePipelineViewModel;
public CncPageView()
{
InitializeComponent();
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
CncEditorViewModel editorViewModel = DataContext as CncEditorViewModel;
if (editorViewModel == null)
{
try
{
editorViewModel = ContainerLocator.Current?.Resolve<CncEditorViewModel>();
DataContext = editorViewModel;
}
catch (Exception)
{
// keep existing DataContext if resolution fails
}
}
if (editorViewModel == null || _inspectionModulePipelineViewModel != null)
return;
try
{
var imageProcessingService = ContainerLocator.Current.Resolve<IImageProcessingService>();
var persistenceService = ContainerLocator.Current.Resolve<IPipelinePersistenceService>();
var logger = ContainerLocator.Current.Resolve<ILoggerService>();
_inspectionModulePipelineViewModel = new CncInspectionModulePipelineViewModel(
editorViewModel,
imageProcessingService,
persistenceService,
logger);
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
InspectionModulePipelineEmptyState.DataContext = _inspectionModulePipelineViewModel;
}
catch (Exception)
{
// keep page usable even if pipeline editor host setup fails
}
}
private void CncTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (DataContext is CncEditorViewModel viewModel)
{
viewModel.SelectedNode = e.NewValue as CncNodeViewModel;
}
}
}
public class NullToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var invert = string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase);
var isVisible = value != null;
if (invert)
{
isVisible = !isVisible;
}
return isVisible ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
}
@@ -11,23 +11,25 @@
<UserControl.Resources>
<SolidColorBrush x:Key="PanelBg" Color="White" />
<SolidColorBrush x:Key="PanelBorder" Color="#CDCBCB" />
<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="ListBoxItem">
<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="52" />
<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="BorderBrush" Value="#cdcbcb" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontSize" Value="11" />
@@ -42,13 +44,21 @@
CornerRadius="4">
<Grid>
<Grid.RowDefinitions>
<!-- Row 0: 工具栏 -->
<RowDefinition Height="Auto" />
<!-- Row 2: 流水线节点列表 -->
<RowDefinition Height="2*" MinHeight="180" />
<!-- Row 3: 分隔线 -->
<RowDefinition Height="Auto" />
<!-- Row 4: 参数面板 -->
<RowDefinition Height="2*" MinHeight="80" />
<!-- Row 5: 状态栏 -->
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 标题栏:流水线名称 + 设备选择 -->
<!-- 工具栏 -->
<Border
Grid.Row="0"
Padding="6,4"
@@ -66,23 +76,50 @@
Command="{Binding SavePipelineCommand}"
Content="保存"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存当前检测模块流水线" />
ToolTip="保存流水线" />
<Button
Width="60"
Width="43"
Command="{Binding SaveAsPipelineCommand}"
Content="另存为"
Style="{StaticResource ToolbarBtn}"
ToolTip="导出当前检测模块流水线" />
ToolTip="另存为" />
<Button
Width="52"
Width="43"
Command="{Binding LoadPipelineCommand}"
Content="加载"
Style="{StaticResource ToolbarBtn}"
ToolTip="流水线模板加载到当前检测模块" />
ToolTip="加载流水线" />
<!--
<Button
Width="64"
Command="{Binding LoadImageCommand}"
Content="加载图像"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载输入图像" />
-->
<Button
Command="{Binding ExecutePipelineCommand}"
Content="▶"
Style="{StaticResource ToolbarBtn}"
ToolTip="执行流水线" />
<Button
Command="{Binding CancelExecutionCommand}"
Content="■"
Style="{StaticResource ToolbarBtn}"
ToolTip="取消执行" />
<Button
Command="{Binding DeletePipelineCommand}"
Content="🗑"
Style="{StaticResource ToolbarBtn}"
ToolTip="工具箱" />
</StackPanel>
</Grid>
</Border>
<!-- 流水线节点列表(拖拽目标) -->
<ListBox
x:Name="PipelineListBox"
Grid.Row="1"
@@ -101,6 +138,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- 连接线列:上半段 + 下半段 -->
<Line
x:Name="TopLine"
HorizontalAlignment="Center"
@@ -121,6 +159,7 @@
Y1="0"
Y2="14" />
<!-- 算子图标 -->
<Border
Grid.Column="0"
Width="28"
@@ -138,6 +177,7 @@
Text="{Binding IconPath}" />
</Border>
<!-- 算子名称 -->
<TextBlock
Grid.Column="1"
Margin="6,0,0,0"
@@ -146,6 +186,7 @@
FontSize="12"
Text="{Binding DisplayName}" />
<!-- 操作按钮:上移 / 下移 / 删除(悬停显示) -->
<StackPanel
x:Name="NodeActions"
Grid.Column="2"
@@ -158,11 +199,11 @@
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#CDCBCB"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="上移" />
@@ -171,11 +212,11 @@
Height="22"
Margin="1,0"
Background="Transparent"
BorderBrush="#CDCBCB"
BorderBrush="#cdcbcb"
BorderThickness="1"
Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="下移" />
@@ -188,7 +229,7 @@
BorderThickness="1"
Command="{Binding DataContext.RemoveOperatorCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
Content=""
Content=""
Cursor="Hand"
FontSize="10"
ToolTip="删除" />
@@ -205,12 +246,13 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- 分隔线 -->
<Rectangle
Grid.Row="2"
Height="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- 参数面板 -->
<ScrollViewer
Grid.Row="3"
HorizontalScrollBarVisibility="Disabled"
@@ -241,14 +283,14 @@
<TextBox
Grid.Column="1"
Padding="4,2"
BorderBrush="#CDCBCB"
BorderBrush="#cdcbcb"
BorderThickness="1"
FontFamily="Microsoft YaHei UI"
FontSize="11"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="#CDCBCB" />
<Setter Property="BorderBrush" Value="#cdcbcb" />
<Setter Property="Background" Value="White" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsValueValid}" Value="False">
@@ -266,6 +308,7 @@
</StackPanel>
</ScrollViewer>
<!-- 状态栏 -->
<Border
Grid.Row="4"
Padding="6,4"
@@ -1,5 +1,5 @@
using Prism.Ioc;
using System;
using Prism.Ioc;
using System.Windows;
using System.Windows.Controls;
using XP.Common.Logging.Interfaces;
@@ -23,7 +23,7 @@ namespace XplorePlane.Views
private void OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext is not IPipelineEditorHostViewModel)
if (DataContext is not PipelineEditorViewModel)
{
try
{
@@ -46,35 +46,36 @@ namespace XplorePlane.Views
private void OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(OperatorToolboxView.DragFormat)
? DragDropEffects.Copy
: DragDropEffects.None;
if (e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
e.Handled = true;
}
private void OnOperatorDropped(object sender, DragEventArgs e)
{
if (DataContext is not IPipelineEditorHostViewModel vm)
if (DataContext is not PipelineEditorViewModel vm)
{
_logger?.Warn("Drop 事件触发但 DataContext 不是流水线宿主 ViewModel");
_logger?.Warn("Drop 事件触发但 DataContext 不是 PipelineEditorViewModel");
return;
}
if (!e.Data.GetDataPresent(OperatorToolboxView.DragFormat))
{
_logger?.Warn("Drop 事件触发但数据中没有 {Format}", OperatorToolboxView.DragFormat);
_logger?.Warn("Drop 事件触发但数据中没有 {Format}", OperatorToolboxView.DragFormat);
return;
}
var operatorKey = e.Data.GetData(OperatorToolboxView.DragFormat) as string;
if (string.IsNullOrWhiteSpace(operatorKey))
if (string.IsNullOrEmpty(operatorKey))
{
_logger?.Warn("Drop 事件触发但 OperatorKey 为空");
_logger?.Warn("Drop 事件触发但 OperatorKey 为空");
return;
}
_logger?.Info("算子已放入流水线:{OperatorKey},当前节点数(执行前)={Count}",
operatorKey, vm.PipelineNodes.Count);
_logger?.Info("算子已放入流水线:{OperatorKey}VM HashCode={Hash}当前节点数(执行前)={Count}",
operatorKey, vm.GetHashCode(), vm.PipelineNodes.Count);
vm.AddOperatorCommand.Execute(operatorKey);
_logger?.Info("AddOperator 执行后节点数={Count}PipelineListBox.Items.Count={ItemsCount}",
vm.PipelineNodes.Count, PipelineListBox.Items.Count);
+4 -3
View File
@@ -1,9 +1,10 @@
<UserControl
<UserControl
x:Class="XplorePlane.Views.ImagePanelView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:XplorePlane.Views"
d:DesignHeight="400"
d:DesignWidth="250"
mc:Ignorable="d">
@@ -13,9 +14,9 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,0,0,1">
<TextBlock Margin="4,2" HorizontalAlignment="Left" VerticalAlignment="Center"
<TextBlock Margin="8,4" HorizontalAlignment="Left" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="#333333" Text="图像" />
</Border>
<ContentControl Grid.Row="1" Content="{Binding ImagePanelContent}" />
<views:PipelineEditorView Grid.Row="1" />
</Grid>
</UserControl>
+17 -29
View File
@@ -16,7 +16,7 @@
Height="1040"
d:DesignWidth="1580"
Background="#F5F5F5"
Icon="pack://application:,,,/XplorePlane;component/XplorerPlane.ico"
Icon="pack://application:,,,/XplorerPlane.ico"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.Resources>
@@ -74,7 +74,7 @@
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="新建CNC"
Command="{Binding NewCncProgramCommand}"
Command="{Binding Path=SetStyle.Command}"
Size="Medium"
SmallImage="/Assets/Icons/new-doc.png"
Text="新建CNC" />
@@ -82,12 +82,11 @@
telerik:ScreenTip.Description="保存当前X射线实时图像"
telerik:ScreenTip.Title="保存图像"
Size="Medium"
Command="{Binding SaveCncProgramCommand}"
SmallImage="/Assets/Icons/save.png"
Text="保存" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="另存为"
Command="{Binding SaveCncProgramCommand}"
Command="{Binding OpenFileCommand}"
Size="Medium"
SmallImage="/Assets/Icons/saveas.png"
Text="另存为" />
@@ -95,7 +94,7 @@
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="加载CNC"
Command="{Binding LoadCncProgramCommand}"
Command="{Binding OpenFileCommand}"
Size="Large"
SmallImage="/Assets/Icons/open.png"
Text="加载CNC" />
@@ -285,28 +284,32 @@
Command="{Binding OpenCncEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/cnc.png"
Text="CNC 编辑" />
Text="CNC 编辑" />
<!-- 矩阵编排入口按钮 -->
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
<!-- CNC 节点快捷工具 -->
<StackPanel>
<telerik:RadRibbonButton
telerik:ScreenTip.Title="参考点"
Size="Medium"
Command="{Binding InsertReferencePointCommand}"
SmallImage="/Assets/Icons/reference.png"
Text="参考点" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="添加位置"
Size="Medium"
Command="{Binding InsertSavePositionCommand}"
SmallImage="/Assets/Icons/add-pos.png"
Text="添加位置" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="完成"
Size="Medium"
Command="{Binding InsertCompleteProgramCommand}"
SmallImage="/Assets/Icons/finish.png"
Text="完成" />
</StackPanel>
@@ -314,19 +317,16 @@
<telerik:RadRibbonButton
telerik:ScreenTip.Title="标记"
Size="Medium"
Command="{Binding InsertInspectionMarkerCommand}"
SmallImage="/Assets/Icons/mark.png"
Text="标记" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="模块"
Size="Medium"
Command="{Binding InsertInspectionModuleCommand}"
SmallImage="/Assets/Icons/Module.png"
Text="检测模块" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="全部保存"
Size="Medium"
Command="{Binding SaveCncProgramCommand}"
SmallImage="/Assets/Icons/saveall.png"
Text="全部保存" />
</StackPanel>
@@ -334,26 +334,14 @@
<telerik:RadRibbonButton
telerik:ScreenTip.Title="消息"
Size="Medium"
Command="{Binding InsertPauseDialogCommand}"
SmallImage="/Assets/Icons/message.png"
Text="消息弹窗" />
<telerik:RadRibbonButton
telerik:ScreenTip.Title="等待"
Size="Medium"
Command="{Binding InsertWaitDelayCommand}"
SmallImage="/Assets/Icons/wait.png"
Text="插入等待" />
</StackPanel>
<!-- 矩阵编排入口按钮 -->
<telerik:RadRibbonButton
telerik:ScreenTip.Description="打开矩阵编排窗口,配置多工件阵列检测方案"
telerik:ScreenTip.Title="矩阵编排"
Command="{Binding OpenMatrixEditorCommand}"
Size="Large"
SmallImage="/Assets/Icons/matrix.png"
Text="矩阵编排" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup Header="高级模块" IsEnabled="{Binding Path=CellsGroup.IsEnabled}">
@@ -400,7 +388,7 @@
Size="Large"
SmallImage="/Assets/Icons/spiral.png" />
</telerik:RadRibbonGroup>
<!--
<!--
<telerik:RadRibbonGroup Header="图像处理">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
@@ -470,9 +458,9 @@
Margin="0">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="NavColumn" Width="0" />
<ColumnDefinition Width="{Binding ViewportPanelWidth}" />
<ColumnDefinition Width="{Binding ImagePanelWidth}" />
<ColumnDefinition Width="300" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="350" />
<ColumnDefinition Width="350" />
</Grid.ColumnDefinitions>
<!-- 左侧: 计划导航 (默认隐藏,点击CNC AccountingNumberFormatButton显示) -->
@@ -12,7 +12,6 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 标题栏 -->
@@ -56,50 +55,5 @@
<Run Foreground="#0078D4" Text="{Binding CameraPixelCoord, Mode=OneWay}" />
</TextBlock>
</Border>
<!-- 控制按钮栏 -->
<Border
Grid.Row="3"
Background="#F0F0F0"
BorderBrush="#DDDDDD"
BorderThickness="0,1,0,0"
Padding="4">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button
Command="{Binding ConnectCameraCommand}"
Width="60" Height="26"
Margin="2"
Background="#4CAF50"
Foreground="#000000"
Content="连接" />
<Button
Command="{Binding DisconnectCameraCommand}"
Width="60" Height="26"
Margin="2"
Background="#F44336"
Foreground="#000000"
Content="断开" />
<Button
Command="{Binding StartGrabCommand}"
Width="60" Height="26"
Margin="2"
Background="#2196F3"
Foreground="#000000"
Content="采集" />
<Button
Command="{Binding StopGrabCommand}"
Width="60" Height="26"
Margin="2"
Background="#FF9800"
Foreground="#000000"
Content="停止" />
<CheckBox
IsChecked="{Binding IsLiveViewEnabled}"
VerticalAlignment="Center"
Margin="6,0,0,0"
Foreground="#333333"
Content="实时" />
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -28,8 +28,16 @@ namespace XplorePlane.Views
}
}
/// <summary>
/// 双击相机图像时,计算并显示点击位置的像素坐标。
/// </summary>
/// <remarks>
/// TODO: 后续需要将点击的像素坐标通过 CalibrationProcessor 转换为世界坐标,
/// 再传给运动机构执行定位。
/// </remarks>
private void ImgCamera_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount != 2) return;
if (_viewModel?.CameraImageSource == null) return;
var image = (Image)sender;
@@ -19,7 +19,7 @@
<!-- 标题栏 -->
<Border Grid.Row="0" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,0,0,1">
<TextBlock Margin="4,2" HorizontalAlignment="Left" VerticalAlignment="Center"
<TextBlock Margin="8,4" HorizontalAlignment="Left" VerticalAlignment="Center"
FontWeight="SemiBold" Foreground="#333333" Text="实时图像" />
</Border>
@@ -30,7 +30,7 @@
<!-- 图像信息栏 -->
<Border Grid.Row="2" Background="#F0F0F0" BorderBrush="#DDDDDD" BorderThickness="0,1,0,0">
<TextBlock Margin="4,2" FontSize="11" Foreground="#666666"
<TextBlock Margin="8,2" FontSize="11" Foreground="#666666"
Text="{Binding ImageInfo}" />
</Border>
</Grid>
-3
View File
@@ -146,9 +146,6 @@
<Link>Libs\Hardware\zh-TW\%(Filename)%(Extension)</Link>
</None>
<Compile Remove="Views\Main\MainWindowB.xaml.cs" />
<Content Include="XplorerPlane.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Resource Include="XplorerPlane.ico" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 20 KiB