From 9218384e3f8141d4689ebccc112b9222f7219093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BC=9F?= Date: Mon, 20 Apr 2026 16:09:17 +0800 Subject: [PATCH] =?UTF-8?q?TURBO-569=EF=BC=9A=E6=9B=B4=E6=96=B0=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E7=BB=93=E6=9E=84=EF=BC=9B=E5=B0=86=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E7=9B=B8=E6=9C=BA=E6=A0=87=E5=AE=9A=E5=92=8C=E6=A0=A1=E5=87=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E8=BF=81=E7=A7=BB=E5=88=B0XP.Camera=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => Basler}/BaslerCameraController.cs | 0 .../CalibrationLocalizedStrings.cs | 8 + XP.Camera/Calibration/CalibrationProcessor.cs | 201 ++++++++++++++ XP.Camera/Calibration/ChessboardCalibrator.cs | 221 +++++++++++++++ .../Controls/CalibrationControl.xaml | 176 ++++++++++++ .../Controls/CalibrationControl.xaml.cs | 104 +++++++ .../ChessboardCalibrationControl.xaml | 208 ++++++++++++++ .../ChessboardCalibrationControl.xaml.cs | 46 ++++ .../Controls/ImageCanvasControl.xaml | 33 +++ .../Controls/ImageCanvasControl.xaml.cs | 229 ++++++++++++++++ .../DefaultCalibrationDialogService.cs | 50 ++++ XP.Camera/Calibration/IDialogService.cs | 15 + .../CalibrationResources.Designer.cs | 104 +++++++ .../Resources/CalibrationResources.en-US.resx | 259 ++++++++++++++++++ .../Resources/CalibrationResources.resx | 259 ++++++++++++++++++ .../ViewModels/CalibrationViewModel.cs | 158 +++++++++++ .../ChessboardCalibrationViewModel.cs | 244 +++++++++++++++++ XP.Camera/{ => Converters}/PixelConverter.cs | 0 XP.Camera/{ => Core}/CameraExceptions.cs | 0 XP.Camera/{ => Core}/CameraFactory.cs | 0 XP.Camera/{ => Core}/CameraModels.cs | 0 XP.Camera/{ => Core}/ICameraController.cs | 0 XP.Camera/README.md | 221 +++++++-------- XP.Camera/XP.Camera.csproj | 17 ++ 24 files changed, 2429 insertions(+), 124 deletions(-) rename XP.Camera/{ => Basler}/BaslerCameraController.cs (100%) create mode 100644 XP.Camera/Calibration/CalibrationLocalizedStrings.cs create mode 100644 XP.Camera/Calibration/CalibrationProcessor.cs create mode 100644 XP.Camera/Calibration/ChessboardCalibrator.cs create mode 100644 XP.Camera/Calibration/Controls/CalibrationControl.xaml create mode 100644 XP.Camera/Calibration/Controls/CalibrationControl.xaml.cs create mode 100644 XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml create mode 100644 XP.Camera/Calibration/Controls/ChessboardCalibrationControl.xaml.cs create mode 100644 XP.Camera/Calibration/Controls/ImageCanvasControl.xaml create mode 100644 XP.Camera/Calibration/Controls/ImageCanvasControl.xaml.cs create mode 100644 XP.Camera/Calibration/DefaultCalibrationDialogService.cs create mode 100644 XP.Camera/Calibration/IDialogService.cs create mode 100644 XP.Camera/Calibration/Resources/CalibrationResources.Designer.cs create mode 100644 XP.Camera/Calibration/Resources/CalibrationResources.en-US.resx create mode 100644 XP.Camera/Calibration/Resources/CalibrationResources.resx create mode 100644 XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs create mode 100644 XP.Camera/Calibration/ViewModels/ChessboardCalibrationViewModel.cs rename XP.Camera/{ => Converters}/PixelConverter.cs (100%) rename XP.Camera/{ => Core}/CameraExceptions.cs (100%) rename XP.Camera/{ => Core}/CameraFactory.cs (100%) rename XP.Camera/{ => Core}/CameraModels.cs (100%) rename XP.Camera/{ => Core}/ICameraController.cs (100%) diff --git a/XP.Camera/BaslerCameraController.cs b/XP.Camera/Basler/BaslerCameraController.cs similarity index 100% rename from XP.Camera/BaslerCameraController.cs rename to XP.Camera/Basler/BaslerCameraController.cs 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 +