diff --git a/README.md b/README.md
index a4c8aac..ca86f50 100644
--- a/README.md
+++ b/README.md
@@ -1,110 +1,110 @@
-## XplorePlane 平面CT软件
-
-### 系统目标
-
-XplorePlane 系统用于控制平面 CT 设备的各个子系统(射线源、探测器、运动控制、相机)并完成采集图像的处理与分析,为研发与调试提供统一的软件平台。
-
-### 总体架构
-
-- 客户端框架: WPF + Prism MVVM(目标框架 net8.0-windows)
-- 图像处理内核: ImageProcessing.Core(算子基类)+ ImageProcessing.Processors(具体算子),基于 EmguCV
-- 相机控制: XP.Camera(Basler 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/ # 硬件库 DLL(XP.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.Camera(Basler 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/ # 硬件库 DLL(XP.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] 主界面硬件栏相机设置按钮
+- [ ] 打通与硬件层的调用流程
+- [ ] 打通与图像层的调用流程
diff --git a/XP.Camera/BaslerCameraController.cs b/XP.Camera/Basler/BaslerCameraController.cs
similarity index 99%
rename from XP.Camera/BaslerCameraController.cs
rename to XP.Camera/Basler/BaslerCameraController.cs
index e6acaca..99b7aae 100644
--- a/XP.Camera/BaslerCameraController.cs
+++ b/XP.Camera/Basler/BaslerCameraController.cs
@@ -195,7 +195,6 @@ public class BaslerCameraController : ICameraController
}
_camera.ExecuteSoftwareTrigger();
- _logger.Debug("Software trigger executed.");
}
catch (TimeoutException)
{
diff --git a/XP.Camera/Calibration/CalibrationLocalizedStrings.cs b/XP.Camera/Calibration/CalibrationLocalizedStrings.cs
new file mode 100644
index 0000000..a914b9c
--- /dev/null
+++ b/XP.Camera/Calibration/CalibrationLocalizedStrings.cs
@@ -0,0 +1,8 @@
+using XP.Camera.Calibration.Resources;
+
+namespace XP.Camera.Calibration;
+
+public class CalibrationLocalizedStrings
+{
+ public CalibrationResources Resources { get; } = new CalibrationResources();
+}
diff --git a/XP.Camera/Calibration/CalibrationProcessor.cs b/XP.Camera/Calibration/CalibrationProcessor.cs
new file mode 100644
index 0000000..9ec1efb
--- /dev/null
+++ b/XP.Camera/Calibration/CalibrationProcessor.cs
@@ -0,0 +1,201 @@
+// ============================================================================
+// 文件名: CalibrationProcessor.cs
+// 描述: 标定处理器,实现图像坐标系到世界坐标系的转换
+// 功能:
+// - 基于多点标定计算透视变换矩阵(支持4点及以上)
+// - 像素坐标到世界坐标的转换
+// - 标定数据的保存和加载(JSON格式)
+// - 从CSV文件导入标定点数据
+// 算法: 使用DLT(Direct Linear Transformation)方法求解单应性矩阵
+// ============================================================================
+
+using Emgu.CV;
+using System.IO;
+using System.Text.Json;
+
+namespace XP.Camera.Calibration;
+
+///
+/// 九点标定处理器
+///
+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 Points { get; set; } = new List();
+ public double[] TransformMatrix { get; set; } = new double[9];
+ public DateTime CalibrationTime { get; set; }
+ }
+
+ private Matrix? _transformMatrix;
+
+ ///
+ /// 执行九点标定
+ ///
+ public bool Calibrate(List points)
+ {
+ if (points.Count < 4)
+ return false;
+
+ int n = points.Count;
+ var A = new Matrix(2 * n, 8);
+ var b = new Matrix(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(8, 1);
+ CvInvoke.Solve(A, b, h, Emgu.CV.CvEnum.DecompMethod.Svd);
+
+ _transformMatrix = new Matrix(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;
+ }
+
+ ///
+ /// 像素坐标转世界坐标
+ ///
+ 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);
+ }
+
+ ///
+ /// 保存标定文件
+ ///
+ public void SaveCalibration(string filePath, List 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);
+ }
+
+ ///
+ /// 加载标定文件
+ ///
+ public bool LoadCalibration(string filePath)
+ {
+ if (!File.Exists(filePath))
+ return false;
+
+ try
+ {
+ var json = File.ReadAllText(filePath);
+ var data = JsonSerializer.Deserialize(json);
+
+ if (data == null || data.TransformMatrix == null || data.TransformMatrix.Length != 9)
+ return false;
+
+ _transformMatrix = new Matrix(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;
+ }
+ }
+
+ ///
+ /// 从CSV文件加载标定点
+ ///
+ public List LoadPointsFromCsv(string filePath)
+ {
+ var points = new List();
+ 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;
+ }
+}
diff --git a/XP.Camera/Calibration/ChessboardCalibrator.cs b/XP.Camera/Calibration/ChessboardCalibrator.cs
new file mode 100644
index 0000000..613d83d
--- /dev/null
+++ b/XP.Camera/Calibration/ChessboardCalibrator.cs
@@ -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();
+ public double ReprojectionError { get; set; }
+ public DateTime CalibrationTime { get; set; }
+ }
+
+ private Mat? _cameraMatrix;
+ private Mat? _distCoeffs;
+ private double _reprojectionError;
+ private List _perImageErrors = new List();
+
+ public double ReprojectionError => _reprojectionError;
+ public List PerImageErrors => _perImageErrors;
+
+ // 进度报告委托
+ public delegate void ProgressReportHandler(int current, int total, string message);
+
+ public event ProgressReportHandler? ProgressChanged;
+
+ public bool CalibrateFromImages(List 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(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;
+ }
+ }
+}
diff --git a/XP.Camera/Calibration/Controls/CalibrationControl.xaml b/XP.Camera/Calibration/Controls/CalibrationControl.xaml
new file mode 100644
index 0000000..a7bc653
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/CalibrationControl.xaml
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XP.Camera/Calibration/Controls/CalibrationControl.xaml.cs b/XP.Camera/Calibration/Controls/CalibrationControl.xaml.cs
new file mode 100644
index 0000000..84a72cf
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/CalibrationControl.xaml.cs
@@ -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);
+ }
+ }
+}
diff --git a/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml b/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml
new file mode 100644
index 0000000..dca8b2d
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml.cs b/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml.cs
new file mode 100644
index 0000000..d7425ab
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml.cs
@@ -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));
+ }
+}
diff --git a/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml b/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml
new file mode 100644
index 0000000..27940d6
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml.cs b/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml.cs
new file mode 100644
index 0000000..e3d2b61
--- /dev/null
+++ b/XP.Camera/Calibration/Controls/ImageCanvasControl.xaml.cs
@@ -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;
+
+///
+/// 图像画布控件 - 提供图像显示、缩放、平移功能
+///
+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? CanvasRightMouseUp;
+ public event EventHandler? CanvasRightMouseDown;
+ public event EventHandler? CanvasLeftMouseDown;
+ public event EventHandler? CanvasMouseMove;
+ public event EventHandler? 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
+}
diff --git a/XP.Camera/Calibration/DefaultCalibrationDialogService.cs b/XP.Camera/Calibration/DefaultCalibrationDialogService.cs
new file mode 100644
index 0000000..13e456d
--- /dev/null
+++ b/XP.Camera/Calibration/DefaultCalibrationDialogService.cs
@@ -0,0 +1,50 @@
+using Microsoft.Win32;
+using System.Windows;
+using XP.Common.GeneralForm.Views;
+
+namespace XP.Camera.Calibration;
+
+///
+/// 默认对话框服务实现,使用 HexMessageBox 自定义消息框。
+/// 外部项目可直接使用,无需额外依赖。
+///
+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;
+ }
+}
diff --git a/XP.Camera/Calibration/IDialogService.cs b/XP.Camera/Calibration/IDialogService.cs
new file mode 100644
index 0000000..d6a9a5d
--- /dev/null
+++ b/XP.Camera/Calibration/IDialogService.cs
@@ -0,0 +1,15 @@
+namespace XP.Camera.Calibration;
+
+///
+/// 对话框服务接口,用于标定模块的文件选择和消息提示。
+///
+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);
+}
diff --git a/XP.Camera/Calibration/Resources/CalibrationResources.Designer.cs b/XP.Camera/Calibration/Resources/CalibrationResources.Designer.cs
new file mode 100644
index 0000000..18f6cc4
--- /dev/null
+++ b/XP.Camera/Calibration/Resources/CalibrationResources.Designer.cs
@@ -0,0 +1,104 @@
+//------------------------------------------------------------------------------
+//
+// 此代码由工具生成。
+// 如果重新生成代码,将丢失对此文件所做的更改。
+//
+//------------------------------------------------------------------------------
+
+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) ?? "";
+ }
+}
diff --git a/XP.Camera/Calibration/Resources/CalibrationResources.en-US.resx b/XP.Camera/Calibration/Resources/CalibrationResources.en-US.resx
new file mode 100644
index 0000000..033481e
--- /dev/null
+++ b/XP.Camera/Calibration/Resources/CalibrationResources.en-US.resx
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Nine-Point Calibration Tool
+
+
+ Load Image
+
+
+ Load from CSV
+
+
+ Calibrate
+
+
+ Save Calibration
+
+
+ Load Calibration
+
+
+ Show World Coordinates
+
+
+ Calibration Points
+
+
+ Pixel X
+
+
+ Pixel Y
+
+
+ World X
+
+
+ World Y
+
+
+ Ready
+
+
+ Status: Image loaded
+{0}
+Right-click on image to view coordinate conversion
+
+
+ Status: Loaded {0} calibration points from CSV
+{1}
+
+
+ Status: Calibration successful! Using {0} points
+
+
+ Status: Calibration failed
+
+
+ Status: Calibration saved to
+{0}
+
+
+ Status: Calibration loaded from
+{0}
+
+
+ Pixel coordinates: ({0:F2}, {1:F2})
+World coordinates: ({2:F2}, {3:F2})
+
+
+ At least 4 calibration points required!
+
+
+ Success
+
+
+ Calibration completed!
+
+
+ Save successful!
+
+
+ Load successful!
+
+
+ Load failed!
+
+
+
+ Chessboard Calibration Tool
+
+
+ Add Images
+
+
+ Clear List
+
+
+ Calibrate
+
+
+ Save Calibration
+
+
+ Load Calibration
+
+
+ Undistort Image
+
+
+ Chessboard Parameters
+
+
+ Inner Corners Width:
+
+
+ Inner Corners Height:
+
+
+ Square Size (mm):
+
+
+ Calibration Images
+
+
+ Status Information
+
+
+ Ready
+
+
+ Added {0} images
+
+
+ Image list cleared
+
+
+ Calibrating, please wait...
+
+
+ Calibration successful!
+Overall reprojection error: {0:F4} pixels
+
+{1}
+
+
+ Calibration failed: {0}
+
+
+ Calibration saved:
+{0}
+
+
+ Calibration loaded:
+{0}
+
+
+ Image undistorted:
+{0}
+
+
+ Image {0}
+Reprojection error: {1:F4} pixels
+
+
+ Preparing calibration...
+
+
+ Detecting corners ({0}/{1})
+
+
+ Performing camera calibration...
+
+
+ Calculating reprojection errors ({0}/{1})
+
+
+ Calibration complete
+
+
+ Calibration failed
+
+
+ At least 3 images required!
+
+
+ Insufficient valid images, need at least 3, current {0}
+
+
+ Save successful!
+
+
+ Load successful!
+
+
+ Calibration completed!
+
+
+ Image{0}: {1:F4} pixels
+
+
diff --git a/XP.Camera/Calibration/Resources/CalibrationResources.resx b/XP.Camera/Calibration/Resources/CalibrationResources.resx
new file mode 100644
index 0000000..4a91b42
--- /dev/null
+++ b/XP.Camera/Calibration/Resources/CalibrationResources.resx
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ 九点标定工具
+
+
+ 加载图像
+
+
+ 从CSV加载
+
+
+ 执行标定
+
+
+ 保存标定
+
+
+ 加载标定
+
+
+ 显示世界坐标
+
+
+ 标定点列表
+
+
+ 像素X
+
+
+ 像素Y
+
+
+ 世界X
+
+
+ 世界Y
+
+
+ 就绪
+
+
+ 状态:图像已加载
+{0}
+右键点击图像查看坐标转换
+
+
+ 状态:已从CSV加载 {0} 个标定点
+{1}
+
+
+ 状态:标定成功!使用 {0} 个点
+
+
+ 状态:标定失败
+
+
+ 状态:标定文件已保存到
+{0}
+
+
+ 状态:标定文件已加载
+{0}
+
+
+ 像素坐标: ({0:F2}, {1:F2})
+世界坐标: ({2:F2}, {3:F2})
+
+
+ 至少需要4个标定点!
+
+
+ 成功
+
+
+ 标定完成!
+
+
+ 保存成功!
+
+
+ 加载成功!
+
+
+ 加载失败!
+
+
+
+ 棋盘格畸变校正工具
+
+
+ 添加图像
+
+
+ 清空列表
+
+
+ 执行标定
+
+
+ 保存标定
+
+
+ 加载标定
+
+
+ 校正图像
+
+
+ 棋盘格参数
+
+
+ 内角点宽度:
+
+
+ 内角点高度:
+
+
+ 方格尺寸(mm):
+
+
+ 标定图像列表
+
+
+ 状态信息
+
+
+ 就绪
+
+
+ 已添加 {0} 张图像
+
+
+ 已清空图像列表
+
+
+ 正在标定,请稍候...
+
+
+ 标定成功!
+总体重投影误差: {0:F4} 像素
+
+{1}
+
+
+ 标定失败: {0}
+
+
+ 标定已保存:
+{0}
+
+
+ 标定已加载:
+{0}
+
+
+ 已校正图像:
+{0}
+
+
+ 图像 {0}
+重投影误差: {1:F4} 像素
+
+
+ 准备标定...
+
+
+ 检测角点 ({0}/{1})
+
+
+ 执行相机标定...
+
+
+ 计算重投影误差 ({0}/{1})
+
+
+ 标定完成
+
+
+ 标定失败
+
+
+ 至少需要3张图像!
+
+
+ 有效图像不足,需要至少3张,当前{0}张
+
+
+ 保存成功!
+
+
+ 加载成功!
+
+
+ 标定完成!
+
+
+ 图像{0}: {1:F4} 像素
+
+
diff --git a/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs
new file mode 100644
index 0000000..90c7acd
--- /dev/null
+++ b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs
@@ -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? _currentImage;
+ private static readonly ILogger _logger = Log.ForContext();
+ private BitmapSource? _imageSource;
+ private string _statusText = Res.CalibrationStatusReady;
+ private bool _showWorldCoordinates;
+
+ public CalibrationViewModel(ICalibrationDialogService dialogService)
+ {
+ _dialogService = dialogService;
+ CalibrationPoints = new ObservableCollection();
+
+ 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 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(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(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(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? 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);
+}
diff --git a/XP.Camera/Calibration/ViewModels/ChessboardCalibrationViewModel.cs b/XP.Camera/Calibration/ViewModels/ChessboardCalibrationViewModel.cs
new file mode 100644
index 0000000..d06f443
--- /dev/null
+++ b/XP.Camera/Calibration/ViewModels/ChessboardCalibrationViewModel.cs
@@ -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 _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();
+
+ 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 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(_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);
+}
diff --git a/XP.Camera/PixelConverter.cs b/XP.Camera/Converters/PixelConverter.cs
similarity index 100%
rename from XP.Camera/PixelConverter.cs
rename to XP.Camera/Converters/PixelConverter.cs
diff --git a/XP.Camera/CameraExceptions.cs b/XP.Camera/Core/CameraExceptions.cs
similarity index 100%
rename from XP.Camera/CameraExceptions.cs
rename to XP.Camera/Core/CameraExceptions.cs
diff --git a/XP.Camera/CameraFactory.cs b/XP.Camera/Core/CameraFactory.cs
similarity index 100%
rename from XP.Camera/CameraFactory.cs
rename to XP.Camera/Core/CameraFactory.cs
diff --git a/XP.Camera/CameraModels.cs b/XP.Camera/Core/CameraModels.cs
similarity index 100%
rename from XP.Camera/CameraModels.cs
rename to XP.Camera/Core/CameraModels.cs
diff --git a/XP.Camera/ICameraController.cs b/XP.Camera/Core/ICameraController.cs
similarity index 100%
rename from XP.Camera/ICameraController.cs
rename to XP.Camera/Core/ICameraController.cs
diff --git a/XP.Camera/README.md b/XP.Camera/README.md
index c72208a..526db80 100644
--- a/XP.Camera/README.md
+++ b/XP.Camera/README.md
@@ -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();
containerRegistry.RegisterSingleton(() =>
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
-
-```
-
### 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()`),与宿主应用共享同一个日志管道。宿主应用只需在启动时配置 `Log.Logger` 即可。
diff --git a/XP.Camera/XP.Camera.csproj b/XP.Camera/XP.Camera.csproj
index 2b53a87..29d059d 100644
--- a/XP.Camera/XP.Camera.csproj
+++ b/XP.Camera/XP.Camera.csproj
@@ -13,7 +13,24 @@
..\ExternalLibraries\Basler.Pylon.dll
+
+
+
+
+
+
+
+
+
+
+
+ PublicResXFileCodeGenerator
+ CalibrationResources.Designer.cs
+
+
+ CalibrationResources.resx
+
diff --git a/XP.Common/GeneralForm/Views/HexMessageBox.xaml b/XP.Common/GeneralForm/Views/HexMessageBox.xaml
new file mode 100644
index 0000000..8116665
--- /dev/null
+++ b/XP.Common/GeneralForm/Views/HexMessageBox.xaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/XP.Common/GeneralForm/Views/HexMessageBox.xaml.cs b/XP.Common/GeneralForm/Views/HexMessageBox.xaml.cs
new file mode 100644
index 0000000..1d3ec70
--- /dev/null
+++ b/XP.Common/GeneralForm/Views/HexMessageBox.xaml.cs
@@ -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;
+
+///
+/// 自定义消息框,可替代标准 MessageBox
+///
+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;
+ }
+
+ ///
+ /// 显示模态对话框并返回结果
+ ///
+ 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;
+ });
+ }
+
+ ///
+ /// 显示非模态消息
+ ///
+ 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(); }
+}
diff --git a/XP.Common/Helpers/ConfigLoader.cs b/XP.Common/Helpers/ConfigLoader.cs
index dd70a1b..60d29ab 100644
--- a/XP.Common/Helpers/ConfigLoader.cs
+++ b/XP.Common/Helpers/ConfigLoader.cs
@@ -1,4 +1,4 @@
-using System.Configuration;
+using System.Configuration;
using XP.Common.Configs;
using XP.Common.Dump.Configs;
diff --git a/XP.Hardware.MotionControl/Services/MotionControlService.cs b/XP.Hardware.MotionControl/Services/MotionControlService.cs
index a8de00a..af5af20 100644
--- a/XP.Hardware.MotionControl/Services/MotionControlService.cs
+++ b/XP.Hardware.MotionControl/Services/MotionControlService.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
diff --git a/XP.Hardware.PLC/Abstractions/IPlcClient.cs b/XP.Hardware.PLC/Abstractions/IPlcClient.cs
index 6da338f..387ac5f 100644
--- a/XP.Hardware.PLC/Abstractions/IPlcClient.cs
+++ b/XP.Hardware.PLC/Abstractions/IPlcClient.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
diff --git a/XP.Hardware.PLC/Services/PlcService.cs b/XP.Hardware.PLC/Services/PlcService.cs
index d163de6..50d87c3 100644
--- a/XP.Hardware.PLC/Services/PlcService.cs
+++ b/XP.Hardware.PLC/Services/PlcService.cs
@@ -1,4 +1,4 @@
-using Prism.Mvvm;
+using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Linq;
diff --git a/XP.Hardware.RaySource/Module/RaySourceModule.cs b/XP.Hardware.RaySource/Module/RaySourceModule.cs
index c9ef636..23c6545 100644
--- a/XP.Hardware.RaySource/Module/RaySourceModule.cs
+++ b/XP.Hardware.RaySource/Module/RaySourceModule.cs
@@ -1,4 +1,4 @@
-using Prism.Ioc;
+using Prism.Ioc;
using Prism.Modularity;
using System.Resources;
using XP.Common.Localization;
diff --git a/XP.Scan/Attributes/IniKeyAttribute.cs b/XP.Scan/Attributes/IniKeyAttribute.cs
new file mode 100644
index 0000000..304f0cd
--- /dev/null
+++ b/XP.Scan/Attributes/IniKeyAttribute.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace XP.Scan.Attributes
+{
+ ///
+ /// 标记 INI Key 名称(可选,默认使用属性名)| Marks the INI key name (optional, defaults to property name)
+ ///
+ [AttributeUsage(AttributeTargets.Property)]
+ public class IniKeyAttribute : Attribute
+ {
+ public string KeyName { get; }
+
+ public IniKeyAttribute(string keyName)
+ {
+ KeyName = keyName;
+ }
+ }
+}
diff --git a/XP.Scan/Attributes/IniSectionAttribute.cs b/XP.Scan/Attributes/IniSectionAttribute.cs
new file mode 100644
index 0000000..ee67abb
--- /dev/null
+++ b/XP.Scan/Attributes/IniSectionAttribute.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace XP.Scan.Attributes
+{
+ ///
+ /// 标记 INI Section 名称 | Marks the INI section name
+ ///
+ [AttributeUsage(AttributeTargets.Class)]
+ public class IniSectionAttribute : Attribute
+ {
+ public string SectionName { get; }
+
+ public IniSectionAttribute(string sectionName)
+ {
+ SectionName = sectionName;
+ }
+ }
+}
diff --git a/XP.Scan/Documents/ScanConfig.Design.md b/XP.Scan/Documents/ScanConfig.Design.md
new file mode 100644
index 0000000..156e735
--- /dev/null
+++ b/XP.Scan/Documents/ScanConfig.Design.md
@@ -0,0 +1,366 @@
+# XP.Scan 扫描配置文件设计文档
+
+## 1. 需求概述
+
+在平面CT扫描采集过程中,需要将当前扫描的所有参数信息(项目信息、射线源参数、探测器参数、运动控制参数、扫描配置、校正配置)打包为一个配置对象,最终序列化为 INI 格式文件传递给重构电脑。
+
+INI 文件结构与立式CT采集配置兼容,包含 6 个 Section:
+- `[Project_Information]` — 项目基本信息
+- `[XRay]` — 射线源参数
+- `[Detector]` — 探测器参数
+- `[Move_Control]` — 运动控制轴位置
+- `[Scan_Config]` — 扫描配置
+- `[Correction_Config]` — 校正参数
+
+## 2. 设计方案对比
+
+| 方案 | 优点 | 缺点 | 推荐 |
+|------|------|------|------|
+| A. 单个大类 `ScanConfig` | 简单直接 | 字段太多,职责不清 | ✗ |
+| B. 分组模型 + 序列化服务 | 职责清晰,可测试,可扩展 | 类稍多 | ✓ |
+| C. Dictionary + 手写序列化 | 灵活 | 无类型安全,易出错 | ✗ |
+
+**选择方案 B**:用分组模型类映射 INI 的每个 Section,加一个序列化服务负责读写 INI 文件。
+
+## 3. 数据模型设计
+
+### 3.1 整体结构
+
+```
+ScanConfigData (顶层聚合)
+├── ProjectInfo → [Project_Information]
+├── XRayConfig → [XRay]
+├── DetectorConfig → [Detector]
+├── MoveControlConfig → [Move_Control]
+├── ScanSettings → [Scan_Config]
+└── CorrectionConfig → [Correction_Config]
+```
+
+### 3.2 类定义
+
+```csharp
+namespace XP.Scan.Models
+{
+ ///
+ /// 扫描配置数据(顶层聚合,对应完整 INI 文件)
+ ///
+ public class ScanConfigData
+ {
+ public ProjectInfo ProjectInfo { get; set; } = new();
+ public XRayConfig XRay { get; set; } = new();
+ public DetectorConfig Detector { get; set; } = new();
+ public MoveControlConfig MoveControl { get; set; } = new();
+ public ScanSettings ScanSettings { get; set; } = new();
+ public CorrectionConfig Correction { get; set; } = new();
+ }
+}
+```
+
+#### [Project_Information]
+
+```csharp
+public class ProjectInfo
+{
+ /// 图像保存路径
+ public string FileSave { get; set; } = string.Empty;
+
+ /// 滤波片1
+ public string Filter1 { get; set; } = "None";
+
+ /// 滤波片2
+ public string Filter2 { get; set; } = "None";
+
+ /// 项目名称
+ public string Project { get; set; } = string.Empty;
+
+ /// 样品编号
+ public string SampleNo { get; set; } = string.Empty;
+
+ /// 扫描模式名称
+ public string ScanMode { get; set; } = "QuickScan";
+}
+```
+
+#### [XRay]
+
+```csharp
+public class XRayConfig
+{
+ /// 管电流 (μA)
+ public int Current_uA { get; set; }
+
+ /// 焦点尺寸
+ public string Focus { get; set; } = string.Empty;
+
+ /// 管电压 (kV)
+ public int Voltage_kV { get; set; }
+}
+```
+
+#### [Detector]
+
+```csharp
+public class DetectorConfig
+{
+ /// 帧合并数
+ public int Det_Avg_Frames { get; set; } = 1;
+
+ /// Binning 模式
+ public string Det_Binning { get; set; } = "1*1";
+
+ /// 帧率
+ public int Det_Frame_rate { get; set; } = 2;
+
+ /// 增益 (PGA)
+ public int Det_PGA { get; set; } = 6;
+
+ // ROI 参数
+ public int Image_ROI_Height { get; set; }
+ public int Image_ROI_Width { get; set; }
+ public int Image_ROI_xStart { get; set; }
+ public int Image_ROI_xEnd { get; set; }
+ public int Image_ROI_yStart { get; set; }
+ public int Image_ROI_yEnd { get; set; }
+ public int Image_ROI_zStart { get; set; }
+ public int Image_ROI_zEnd { get; set; }
+
+ // 图像尺寸
+ public int Image_Size_Height { get; set; }
+ public int Image_Size_Width { get; set; }
+
+ // 物理尺寸 (mm)
+ public double Physical_Size_X { get; set; }
+ public double Physical_Size_Y { get; set; }
+
+ // 像素尺寸 (mm)
+ public double Pixel_X { get; set; }
+ public double Pixel_Y { get; set; }
+}
+```
+
+#### [Move_Control]
+
+```csharp
+public class MoveControlConfig
+{
+ /// 探测器 X 位置 (mm)
+ public double DetX { get; set; }
+
+ /// 探测器 Y 位置 (mm)
+ public double DetY { get; set; }
+
+ /// 探测器 Z 位置 (mm)
+ public double DetZ { get; set; }
+
+ /// 旋转台角度 (°)
+ public double Rotation { get; set; }
+
+ /// 样品台 X 位置 (mm) — 即 SOD
+ public double X { get; set; }
+
+ /// 射线源 Z 位置 (mm)
+ public double XRAYZ { get; set; }
+
+ /// 样品台 Y 位置 (mm)
+ public double Y { get; set; }
+}
+```
+
+#### [Scan_Config]
+
+```csharp
+public class ScanSettings
+{
+ /// 采集张数
+ public int AcquiresNums { get; set; }
+
+ /// 旋转角度 (°)
+ public double RotateDegree { get; set; }
+
+ /// 扫描模式描述
+ public string ScanMode { get; set; } = string.Empty;
+
+ /// SDD — 射线源到探测器距离 (mm)
+ public double SDD { get; set; }
+
+ /// SOD — 射线源到样品距离 (mm)
+ public double SOD { get; set; }
+}
+```
+
+#### [Correction_Config]
+
+```csharp
+public class CorrectionConfig
+{
+ /// 探测器水平偏移 (mm)
+ public double Detector_Horizontal_Offset { get; set; }
+
+ /// 探测器旋转偏移 (°)
+ public double Detector_Rotation_Offset { get; set; }
+}
+```
+
+## 4. INI 序列化服务设计
+
+### 4.1 接口
+
+```csharp
+namespace XP.Scan.Services
+{
+ public interface IScanConfigSerializer
+ {
+ /// 将配置数据序列化为 INI 格式字符串
+ string Serialize(ScanConfigData config);
+
+ /// 将配置数据写入 INI 文件
+ void SaveToFile(ScanConfigData config, string filePath);
+
+ /// 从 INI 文件读取配置数据
+ ScanConfigData LoadFromFile(string filePath);
+ }
+}
+```
+
+### 4.2 实现方案
+
+不引入第三方 INI 库,手写轻量级序列化,原因:
+- INI 结构简单固定(6 个 Section,字段已知)
+- 避免额外 NuGet 依赖
+- 完全可控,格式与立式CT兼容
+
+核心思路:用 `[IniSection("Section_Name")]` 和 `[IniKey("Key_Name")]` 特性标注模型属性,序列化时通过反射自动生成 INI 内容。
+
+### 4.3 特性定义
+
+```csharp
+/// 标记 INI Section 名称
+[AttributeUsage(AttributeTargets.Class)]
+public class IniSectionAttribute : Attribute
+{
+ public string SectionName { get; }
+ public IniSectionAttribute(string sectionName) => SectionName = sectionName;
+}
+
+/// 标记 INI Key 名称(可选,默认用属性名)
+[AttributeUsage(AttributeTargets.Property)]
+public class IniKeyAttribute : Attribute
+{
+ public string KeyName { get; }
+ public IniKeyAttribute(string keyName) => KeyName = keyName;
+}
+```
+
+### 4.4 模型标注示例
+
+```csharp
+[IniSection("Project_Information")]
+public class ProjectInfo
+{
+ [IniKey("fileSave")]
+ public string FileSave { get; set; } = string.Empty;
+
+ [IniKey("filter1")]
+ public string Filter1 { get; set; } = "None";
+
+ // ...
+}
+
+[IniSection("XRay")]
+public class XRayConfig
+{
+ [IniKey("Current_uA")]
+ public int Current_uA { get; set; }
+
+ // ...
+}
+```
+
+### 4.5 序列化输出示例
+
+```ini
+[Project_Information]
+fileSave=D:\HexagonCTData\Test_2026-04-21\Image
+filter1=Cu 0.2mm
+filter2=None
+Project=Test
+SampleNo=
+ScanMode=QuickScan
+
+[XRay]
+Current_uA=1000
+Focus=450um
+Voltage_kV=450
+
+[Detector]
+Det_Avg_Frames=1
+Det_Binning=1*1
+...
+```
+
+## 5. 文件结构规划
+
+```
+XP.Scan/
+├── Models/
+│ ├── ScanConfigData.cs # 顶层聚合类
+│ ├── ProjectInfo.cs # [Project_Information]
+│ ├── XRayConfig.cs # [XRay]
+│ ├── DetectorConfig.cs # [Detector]
+│ ├── MoveControlConfig.cs # [Move_Control]
+│ ├── ScanSettings.cs # [Scan_Config]
+│ └── CorrectionConfig.cs # [Correction_Config]
+│
+├── Attributes/
+│ ├── IniSectionAttribute.cs # Section 特性
+│ └── IniKeyAttribute.cs # Key 特性
+│
+├── Services/
+│ ├── IScanConfigSerializer.cs # 序列化接口
+│ └── ScanConfigSerializer.cs # 序列化实现(反射 + 手写 INI)
+│
+└── ...
+```
+
+## 6. 使用流程
+
+```
+1. 扫描开始前,从各硬件服务收集参数 → 填充 ScanConfigData
+
+ var config = new ScanConfigData();
+ config.XRay.Voltage_kV = raySourceService.CurrentVoltage;
+ config.XRay.Current_uA = raySourceService.CurrentCurrent;
+ config.Detector.Det_Avg_Frames = detectorService.AvgFrames;
+ config.MoveControl.X = motionService.GetPosition(AxisId.StageX);
+ config.ScanSettings.AcquiresNums = acquisitionCount;
+ // ...
+
+2. 序列化为 INI 文件
+
+ var serializer = new ScanConfigSerializer();
+ serializer.SaveToFile(config, @"D:\HexagonCTData\Test\ScanConfig.ini");
+
+3. 传递给重构电脑(文件拷贝或网络传输)
+```
+
+## 7. 设计决策
+
+| 决策 | 选择 | 理由 |
+|------|------|------|
+| 数据模型 | 分组类(每个 Section 一个类) | 职责清晰,属性有类型安全 |
+| 序列化方式 | 自定义特性 + 反射 | 轻量,无第三方依赖,格式完全可控 |
+| INI Key 映射 | `[IniKey]` 特性 | 属性名可以用 C# 命名规范,INI Key 保持与立式CT兼容 |
+| 数值格式 | `InvariantCulture` | 避免不同系统区域设置导致小数点格式不一致 |
+| 文件编码 | UTF-8 无 BOM | 兼容性最好 |
+
+## 8. 扩展性
+
+- 新增 Section:创建新模型类 + 标注 `[IniSection]` + 在 `ScanConfigData` 中添加属性
+- 新增字段:在对应模型类中添加属性 + 标注 `[IniKey]`
+- 反序列化:`LoadFromFile` 支持从 INI 文件回读配置(用于加载历史扫描参数)
+- 验证:可在模型类中添加 `Validate()` 方法,检查参数范围合法性
+
+---
+
+**版本:** 1.0
+**最后更新:** 2026-04-21
diff --git a/XP.Scan/Models/CorrectionConfig.cs b/XP.Scan/Models/CorrectionConfig.cs
new file mode 100644
index 0000000..ecbe9ea
--- /dev/null
+++ b/XP.Scan/Models/CorrectionConfig.cs
@@ -0,0 +1,20 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 校正配置 | Correction configuration
+ /// 对应 INI [Correction_Config] Section
+ ///
+ [IniSection("Correction_Config")]
+ public class CorrectionConfig
+ {
+ /// 探测器水平偏移 (mm) | Detector horizontal offset (mm)
+ [IniKey("Detector_Horizontal_Offset")]
+ public double DetectorHorizontalOffset { get; set; }
+
+ /// 探测器旋转偏移 (°) | Detector rotation offset (°)
+ [IniKey("Detector_Rotation_Offset")]
+ public double DetectorRotationOffset { get; set; }
+ }
+}
diff --git a/XP.Scan/Models/DetectorConfig.cs b/XP.Scan/Models/DetectorConfig.cs
new file mode 100644
index 0000000..7e07a85
--- /dev/null
+++ b/XP.Scan/Models/DetectorConfig.cs
@@ -0,0 +1,84 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 探测器配置 | Detector configuration
+ /// 对应 INI [Detector] Section
+ ///
+ [IniSection("Detector")]
+ public class DetectorConfig
+ {
+ /// 帧合并数 | Average frames
+ [IniKey("Det_Avg_Frames")]
+ public int DetAvgFrames { get; set; } = 1;
+
+ /// Binning 模式 | Binning mode
+ [IniKey("Det_Binning")]
+ public string DetBinning { get; set; } = "1*1";
+
+ /// 帧率 | Frame rate
+ [IniKey("Det_Frame_rate")]
+ public int DetFrameRate { get; set; } = 2;
+
+ /// 增益 (PGA) | Gain (PGA)
+ [IniKey("Det_PGA")]
+ public int DetPGA { get; set; } = 6;
+
+ /// ROI 高度 | ROI height
+ [IniKey("Image_ROI_Height")]
+ public int ImageROIHeight { get; set; }
+
+ /// ROI 宽度 | ROI width
+ [IniKey("Image_ROI_Width")]
+ public int ImageROIWidth { get; set; }
+
+ /// ROI X 起始 | ROI X start
+ [IniKey("Image_ROI_xStart")]
+ public int ImageROIxStart { get; set; }
+
+ /// ROI X 结束 | ROI X end
+ [IniKey("Image_ROI_xEnd")]
+ public int ImageROIxEnd { get; set; }
+
+ /// ROI Y 起始 | ROI Y start
+ [IniKey("Image_ROI_yStart")]
+ public int ImageROIyStart { get; set; }
+
+ /// ROI Y 结束 | ROI Y end
+ [IniKey("Image_ROI_yEnd")]
+ public int ImageROIyEnd { get; set; }
+
+ /// ROI Z 起始 | ROI Z start
+ [IniKey("Image_ROI_zStart")]
+ public int ImageROIzStart { get; set; }
+
+ /// ROI Z 结束 | ROI Z end
+ [IniKey("Image_ROI_zEnd")]
+ public int ImageROIzEnd { get; set; }
+
+ /// 图像高度 | Image height
+ [IniKey("Image_Size_Height")]
+ public int ImageSizeHeight { get; set; }
+
+ /// 图像宽度 | Image width
+ [IniKey("Image_Size_Width")]
+ public int ImageSizeWidth { get; set; }
+
+ /// 物理尺寸 X (mm) | Physical size X (mm)
+ [IniKey("Physical_Size_X")]
+ public double PhysicalSizeX { get; set; }
+
+ /// 物理尺寸 Y (mm) | Physical size Y (mm)
+ [IniKey("Physical_Size_Y")]
+ public double PhysicalSizeY { get; set; }
+
+ /// 像素尺寸 X (mm) | Pixel size X (mm)
+ [IniKey("Pixel_X")]
+ public double PixelX { get; set; }
+
+ /// 像素尺寸 Y (mm) | Pixel size Y (mm)
+ [IniKey("Pixel_Y")]
+ public double PixelY { get; set; }
+ }
+}
diff --git a/XP.Scan/Models/MoveControlConfig.cs b/XP.Scan/Models/MoveControlConfig.cs
new file mode 100644
index 0000000..edaaada
--- /dev/null
+++ b/XP.Scan/Models/MoveControlConfig.cs
@@ -0,0 +1,40 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 运动控制配置 | Motion control configuration
+ /// 对应 INI [Move_Control] Section
+ ///
+ [IniSection("Move_Control")]
+ public class MoveControlConfig
+ {
+ /// 探测器 X 位置 (mm) | Detector X position (mm)
+ [IniKey("DetX")]
+ public double DetX { get; set; }
+
+ /// 探测器 Y 位置 (mm) | Detector Y position (mm)
+ [IniKey("DetY")]
+ public double DetY { get; set; }
+
+ /// 探测器 Z 位置 (mm) | Detector Z position (mm)
+ [IniKey("DetZ")]
+ public double DetZ { get; set; }
+
+ /// 旋转台角度 (°) | Rotation angle (°)
+ [IniKey("Rotation")]
+ public double Rotation { get; set; }
+
+ /// 样品台 X 位置 (mm),即 SOD | Stage X position (mm), i.e. SOD
+ [IniKey("X")]
+ public double X { get; set; }
+
+ /// 射线源 Z 位置 (mm) | X-Ray source Z position (mm)
+ [IniKey("XRAYZ")]
+ public double XRAYZ { get; set; }
+
+ /// 样品台 Y 位置 (mm) | Stage Y position (mm)
+ [IniKey("Y")]
+ public double Y { get; set; }
+ }
+}
diff --git a/XP.Scan/Models/ProjectInfo.cs b/XP.Scan/Models/ProjectInfo.cs
new file mode 100644
index 0000000..50437bb
--- /dev/null
+++ b/XP.Scan/Models/ProjectInfo.cs
@@ -0,0 +1,36 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 项目信息 | Project information
+ /// 对应 INI [Project_Information] Section
+ ///
+ [IniSection("Project_Information")]
+ public class ProjectInfo
+ {
+ /// 图像保存路径 | Image save path
+ [IniKey("fileSave")]
+ public string FileSave { get; set; } = string.Empty;
+
+ /// 滤波片1 | Filter 1
+ [IniKey("filter1")]
+ public string Filter1 { get; set; } = "None";
+
+ /// 滤波片2 | Filter 2
+ [IniKey("filter2")]
+ public string Filter2 { get; set; } = "None";
+
+ /// 项目名称 | Project name
+ [IniKey("Project")]
+ public string Project { get; set; } = string.Empty;
+
+ /// 样品编号 | Sample number
+ [IniKey("SampleNo")]
+ public string SampleNo { get; set; } = string.Empty;
+
+ /// 扫描模式名称 | Scan mode name
+ [IniKey("ScanMode")]
+ public string ScanMode { get; set; } = "QuickScan";
+ }
+}
diff --git a/XP.Scan/Models/ScanConfigData.cs b/XP.Scan/Models/ScanConfigData.cs
new file mode 100644
index 0000000..1d714f4
--- /dev/null
+++ b/XP.Scan/Models/ScanConfigData.cs
@@ -0,0 +1,27 @@
+namespace XP.Scan.Models
+{
+ ///
+ /// 扫描配置数据(顶层聚合,对应完整 INI 文件)
+ /// Scan configuration data (top-level aggregate, corresponds to complete INI file)
+ ///
+ public class ScanConfigData
+ {
+ /// 项目信息 → [Project_Information]
+ public ProjectInfo ProjectInfo { get; set; } = new();
+
+ /// 射线源配置 → [XRay]
+ public XRayConfig XRay { get; set; } = new();
+
+ /// 探测器配置 → [Detector]
+ public DetectorConfig Detector { get; set; } = new();
+
+ /// 运动控制配置 → [Move_Control]
+ public MoveControlConfig MoveControl { get; set; } = new();
+
+ /// 扫描配置 → [Scan_Config]
+ public ScanSettings ScanSettings { get; set; } = new();
+
+ /// 校正配置 → [Correction_Config]
+ public CorrectionConfig Correction { get; set; } = new();
+ }
+}
diff --git a/XP.Scan/Models/ScanSettings.cs b/XP.Scan/Models/ScanSettings.cs
new file mode 100644
index 0000000..54b49f6
--- /dev/null
+++ b/XP.Scan/Models/ScanSettings.cs
@@ -0,0 +1,32 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 扫描配置 | Scan configuration
+ /// 对应 INI [Scan_Config] Section
+ ///
+ [IniSection("Scan_Config")]
+ public class ScanSettings
+ {
+ /// 采集张数 | Number of acquisitions
+ [IniKey("AcquiresNums")]
+ public int AcquiresNums { get; set; }
+
+ /// 旋转角度 (°) | Rotation degree (°)
+ [IniKey("RotateDegree")]
+ public double RotateDegree { get; set; }
+
+ /// 扫描模式描述 | Scan mode description
+ [IniKey("ScanMode")]
+ public string ScanMode { get; set; } = string.Empty;
+
+ /// SDD — 射线源到探测器距离 (mm) | Source to detector distance (mm)
+ [IniKey("SDD")]
+ public double SDD { get; set; }
+
+ /// SOD — 射线源到样品距离 (mm) | Source to object distance (mm)
+ [IniKey("SOD")]
+ public double SOD { get; set; }
+ }
+}
diff --git a/XP.Scan/Models/XRayConfig.cs b/XP.Scan/Models/XRayConfig.cs
new file mode 100644
index 0000000..03d8cc3
--- /dev/null
+++ b/XP.Scan/Models/XRayConfig.cs
@@ -0,0 +1,24 @@
+using XP.Scan.Attributes;
+
+namespace XP.Scan.Models
+{
+ ///
+ /// 射线源配置 | X-Ray source configuration
+ /// 对应 INI [XRay] Section
+ ///
+ [IniSection("XRay")]
+ public class XRayConfig
+ {
+ /// 管电流 (μA) | Tube current (μA)
+ [IniKey("Current_uA")]
+ public int CurrentUA { get; set; }
+
+ /// 焦点尺寸 | Focus size
+ [IniKey("Focus")]
+ public string Focus { get; set; } = string.Empty;
+
+ /// 管电压 (kV) | Tube voltage (kV)
+ [IniKey("Voltage_kV")]
+ public int VoltageKV { get; set; }
+ }
+}
diff --git a/XP.Scan/Services/IScanConfigSerializer.cs b/XP.Scan/Services/IScanConfigSerializer.cs
new file mode 100644
index 0000000..deed1cb
--- /dev/null
+++ b/XP.Scan/Services/IScanConfigSerializer.cs
@@ -0,0 +1,19 @@
+using XP.Scan.Models;
+
+namespace XP.Scan.Services
+{
+ ///
+ /// 扫描配置 INI 序列化接口 | Scan config INI serialization interface
+ ///
+ public interface IScanConfigSerializer
+ {
+ /// 将配置数据序列化为 INI 格式字符串 | Serialize config to INI string
+ string Serialize(ScanConfigData config);
+
+ /// 将配置数据写入 INI 文件 | Save config to INI file
+ void SaveToFile(ScanConfigData config, string filePath);
+
+ /// 从 INI 文件读取配置数据 | Load config from INI file
+ ScanConfigData LoadFromFile(string filePath);
+ }
+}
diff --git a/XP.Scan/Services/ScanConfigSerializer.cs b/XP.Scan/Services/ScanConfigSerializer.cs
new file mode 100644
index 0000000..5a76558
--- /dev/null
+++ b/XP.Scan/Services/ScanConfigSerializer.cs
@@ -0,0 +1,236 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using XP.Scan.Attributes;
+using XP.Scan.Models;
+
+namespace XP.Scan.Services
+{
+ ///
+ /// 扫描配置 INI 序列化实现 | Scan config INI serialization implementation
+ /// 通过反射 + 自定义特性自动生成/解析 INI 内容
+ ///
+ public class ScanConfigSerializer : IScanConfigSerializer
+ {
+ ///
+ /// 将配置数据序列化为 INI 格式字符串
+ ///
+ public string Serialize(ScanConfigData config)
+ {
+ if (config == null) throw new ArgumentNullException(nameof(config));
+
+ var sb = new StringBuilder();
+ var sections = GetSectionObjects(config);
+
+ foreach (var (sectionName, sectionObj) in sections)
+ {
+ sb.AppendLine($"[{sectionName}]");
+ SerializeSection(sb, sectionObj);
+ sb.AppendLine();
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ ///
+ /// 将配置数据写入 INI 文件
+ ///
+ public void SaveToFile(ScanConfigData config, string filePath)
+ {
+ if (string.IsNullOrEmpty(filePath)) throw new ArgumentNullException(nameof(filePath));
+
+ var directory = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var content = Serialize(config);
+ File.WriteAllText(filePath, content, new UTF8Encoding(false));
+ }
+
+ ///
+ /// 从 INI 文件读取配置数据
+ ///
+ public ScanConfigData LoadFromFile(string filePath)
+ {
+ if (!File.Exists(filePath))
+ throw new FileNotFoundException($"INI file not found: {filePath}", filePath);
+
+ var lines = File.ReadAllLines(filePath, Encoding.UTF8);
+ var iniData = ParseIniLines(lines);
+
+ var config = new ScanConfigData();
+ DeserializeSection(iniData, config.ProjectInfo);
+ DeserializeSection(iniData, config.XRay);
+ DeserializeSection(iniData, config.Detector);
+ DeserializeSection(iniData, config.MoveControl);
+ DeserializeSection(iniData, config.ScanSettings);
+ DeserializeSection(iniData, config.Correction);
+
+ return config;
+ }
+
+ #region 序列化辅助方法
+
+ ///
+ /// 获取 ScanConfigData 中所有标注了 [IniSection] 的子对象
+ ///
+ private List<(string SectionName, object SectionObj)> GetSectionObjects(ScanConfigData config)
+ {
+ var result = new List<(string, object)>();
+
+ foreach (var prop in typeof(ScanConfigData).GetProperties())
+ {
+ var obj = prop.GetValue(config);
+ if (obj == null) continue;
+
+ var sectionAttr = obj.GetType().GetCustomAttribute();
+ if (sectionAttr != null)
+ {
+ result.Add((sectionAttr.SectionName, obj));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// 序列化单个 Section 的所有属性为 key=value 行
+ ///
+ private void SerializeSection(StringBuilder sb, object sectionObj)
+ {
+ foreach (var prop in sectionObj.GetType().GetProperties())
+ {
+ var keyAttr = prop.GetCustomAttribute();
+ var keyName = keyAttr?.KeyName ?? prop.Name;
+ var value = prop.GetValue(sectionObj);
+ var valueStr = FormatValue(value);
+
+ sb.AppendLine($"{keyName}={valueStr}");
+ }
+ }
+
+ ///
+ /// 格式化属性值为 INI 字符串(使用 InvariantCulture)
+ ///
+ private string FormatValue(object? value)
+ {
+ if (value == null) return string.Empty;
+
+ return value switch
+ {
+ double d => d.ToString(CultureInfo.InvariantCulture),
+ float f => f.ToString(CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? string.Empty
+ };
+ }
+
+ #endregion
+
+ #region 反序列化辅助方法
+
+ ///
+ /// 解析 INI 文件行为 Section → Key/Value 字典
+ ///
+ private Dictionary> ParseIniLines(string[] lines)
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ string currentSection = string.Empty;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+
+ // 跳过空行和注释
+ if (string.IsNullOrEmpty(line) || line.StartsWith(";") || line.StartsWith("#"))
+ continue;
+
+ // Section 头
+ if (line.StartsWith("[") && line.EndsWith("]"))
+ {
+ currentSection = line.Substring(1, line.Length - 2).Trim();
+ if (!result.ContainsKey(currentSection))
+ {
+ result[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ continue;
+ }
+
+ // Key=Value
+ var eqIndex = line.IndexOf('=');
+ if (eqIndex > 0 && !string.IsNullOrEmpty(currentSection))
+ {
+ var key = line.Substring(0, eqIndex).Trim();
+ var val = line.Substring(eqIndex + 1).Trim();
+ result[currentSection][key] = val;
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// 将 INI 数据反序列化到模型对象
+ ///
+ private void DeserializeSection(Dictionary> iniData, object sectionObj)
+ {
+ var sectionAttr = sectionObj.GetType().GetCustomAttribute();
+ if (sectionAttr == null) return;
+
+ if (!iniData.TryGetValue(sectionAttr.SectionName, out var sectionData))
+ return;
+
+ foreach (var prop in sectionObj.GetType().GetProperties())
+ {
+ var keyAttr = prop.GetCustomAttribute();
+ var keyName = keyAttr?.KeyName ?? prop.Name;
+
+ if (!sectionData.TryGetValue(keyName, out var valueStr))
+ continue;
+
+ try
+ {
+ var convertedValue = ConvertValue(valueStr, prop.PropertyType);
+ if (convertedValue != null)
+ {
+ prop.SetValue(sectionObj, convertedValue);
+ }
+ }
+ catch
+ {
+ // 转换失败时保留默认值
+ }
+ }
+ }
+
+ ///
+ /// 将字符串值转换为目标类型
+ ///
+ private object? ConvertValue(string value, Type targetType)
+ {
+ if (targetType == typeof(string))
+ return value;
+
+ if (targetType == typeof(int))
+ return int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var i) ? i : null;
+
+ if (targetType == typeof(double))
+ return double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null;
+
+ if (targetType == typeof(float))
+ return float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var f) ? f : null;
+
+ if (targetType == typeof(bool))
+ return bool.TryParse(value, out var b) ? b : null;
+
+ return null;
+ }
+
+ #endregion
+ }
+}
diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs
index c9258a7..bd10aa4 100644
--- a/XplorePlane/App.xaml.cs
+++ b/XplorePlane/App.xaml.cs
@@ -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;
@@ -150,7 +151,23 @@ namespace XplorePlane
Log.Error(ex, "射线源资源释放失败");
}
- // 释放相机服务资源
+ // 先停止导航相机实时采集,再释放资源,避免回调死锁
+ try
+ {
+ var bootstrapper = AppBootstrapper.Instance;
+ if (bootstrapper != null)
+ {
+ var cameraVm = bootstrapper.Container.Resolve();
+ cameraVm?.Dispose();
+ Log.Information("导航相机 ViewModel 已释放");
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "导航相机 ViewModel 释放失败");
+ }
+
+ // 释放导航相机服务资源
try
{
var bootstrapper = AppBootstrapper.Instance;
@@ -158,12 +175,12 @@ namespace XplorePlane
{
var cameraService = bootstrapper.Container.Resolve();
cameraService?.Dispose();
- Log.Information("相机服务资源已释放");
+ Log.Information("导航相机服务资源已释放");
}
}
catch (Exception ex)
{
- Log.Error(ex, "相机服务资源释放失败");
+ Log.Error(ex, "导航相机服务资源释放失败");
}
// 释放SQLite数据库资源 | Release SQLite database resources
@@ -232,19 +249,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();
+ var shell = Container.Resolve();
+
+ // 主窗口加载完成后再连接相机,确保所有模块和原生 DLL 已完成初始化
+ shell.Loaded += (s, e) =>
+ {
+ TryConnectCamera();
+
+ // 通知 ViewModel 相机状态已确定,启动实时预览或显示错误
+ try
+ {
+ var cameraVm = Container.Resolve();
+ cameraVm.OnCameraReady();
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "通知相机 ViewModel 失败");
+ }
+
+ if (_cameraError != null)
+ {
+ HexMessageBox.Show(_cameraError, MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ };
+
+ return shell;
+ }
+
+ ///
+ /// 在主线程上检索并连接导航相机。
+ /// pylon SDK 要求在主线程(STA)上操作,不能放到后台线程。
+ ///
+ private void TryConnectCamera()
+ {
+ var camera = Container.Resolve();
+
+ 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}";
+ }
}
///
@@ -329,7 +394,7 @@ namespace XplorePlane
containerRegistry.RegisterForNavigation();
containerRegistry.RegisterForNavigation();
- // ── 相机服务(单例)──
+ // ── 导航相机服务(单例)──
containerRegistry.RegisterSingleton();
containerRegistry.RegisterSingleton(() =>
new CameraFactory().CreateController("Basler"));
diff --git a/XplorePlane/Services/ImageProcessing/ImageConverter.cs b/XplorePlane/Services/ImageProcessing/ImageConverter.cs
index ed28a2b..539e7d1 100644
--- a/XplorePlane/Services/ImageProcessing/ImageConverter.cs
+++ b/XplorePlane/Services/ImageProcessing/ImageConverter.cs
@@ -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(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 ToEmguCVFromPixels(byte[] pixels, int width, int height)
{
var image = new Image(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);
}
diff --git a/XplorePlane/ViewModels/Main/NavigationPropertyPanelViewModel.cs b/XplorePlane/ViewModels/Main/NavigationPropertyPanelViewModel.cs
index b48a240..6253851 100644
--- a/XplorePlane/ViewModels/Main/NavigationPropertyPanelViewModel.cs
+++ b/XplorePlane/ViewModels/Main/NavigationPropertyPanelViewModel.cs
@@ -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 = "正在检索相机...";
+ }
+
+ ///
+ /// 相机连接完成后由外部调用,启动实时预览。
+ ///
+ 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
diff --git a/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml b/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml
index 791f55d..df97103 100644
--- a/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml
+++ b/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml
@@ -12,7 +12,6 @@
-
@@ -56,50 +55,5 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml.cs b/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml.cs
index 520f276..39a7b9c 100644
--- a/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml.cs
+++ b/XplorePlane/Views/Main/NavigationPropertyPanelView.xaml.cs
@@ -28,8 +28,16 @@ namespace XplorePlane.Views
}
}
+ ///
+ /// 双击相机图像时,计算并显示点击位置的像素坐标。
+ ///
+ ///
+ /// TODO: 后续需要将点击的像素坐标通过 CalibrationProcessor 转换为世界坐标,
+ /// 再传给运动机构执行定位。
+ ///
private void ImgCamera_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
+ if (e.ClickCount != 2) return;
if (_viewModel?.CameraImageSource == null) return;
var image = (Image)sender;