diff --git a/XP.Camera/Calibration/ICalibrationCaptureService.cs b/XP.Camera/Calibration/ICalibrationCaptureService.cs new file mode 100644 index 0000000..c60ca15 --- /dev/null +++ b/XP.Camera/Calibration/ICalibrationCaptureService.cs @@ -0,0 +1,73 @@ +using System.Drawing; +using System.Windows.Media.Imaging; + +namespace XP.Camera.Calibration; + +/// +/// 标定采集服务接口 +/// 提供"一键采集"能力:读取编码器坐标 + 拍图 + 识别标记中心 +/// +public interface ICalibrationCaptureService +{ + /// 是否可用(相机已连接、运动系统就绪) + bool IsAvailable { get; } + + /// + /// 采集当前标定点 + /// + /// 采集结果,失败时返回 null + CaptureResult? CaptureCurrentPoint(); + + /// + /// 获取当前导航相机图像 + /// + BitmapSource? CaptureImage(); + + /// + /// 启动实时预览(将相机实时画面推送到 LiveImageUpdated 事件) + /// + void StartLivePreview(); + + /// + /// 停止实时预览 + /// + void StopLivePreview(); + + /// + /// 实时画面更新事件 + /// + event EventHandler? LiveImageUpdated; +} + +/// +/// 实时画面事件参数 +/// +public class LiveImageEventArgs : EventArgs +{ + public BitmapSource Image { get; } + public LiveImageEventArgs(BitmapSource image) => Image = image; +} + +/// +/// 单次采集结果 +/// +public class CaptureResult +{ + /// 标记中心像素坐标 X(亚像素) + public double PixelX { get; set; } + + /// 标记中心像素坐标 Y(亚像素) + public double PixelY { get; set; } + + /// 平台编码器坐标 X (mm) + public double WorldX { get; set; } + + /// 平台编码器坐标 Y (mm) + public double WorldY { get; set; } + + /// 采集的图像 + public BitmapSource? Image { get; set; } + + /// 检测到的标记轮廓点集 + public System.Drawing.Point[]? ContourPoints { get; set; } +} diff --git a/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs index 90c7acd..404b0f9 100644 --- a/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs +++ b/XP.Camera/Calibration/ViewModels/CalibrationViewModel.cs @@ -11,16 +11,20 @@ namespace XP.Camera.Calibration.ViewModels; public class CalibrationViewModel : BindableBase { private readonly ICalibrationDialogService _dialogService; + private readonly ICalibrationCaptureService? _captureService; private readonly CalibrationProcessor _calibrator = new(); private Image? _currentImage; private static readonly ILogger _logger = Log.ForContext(); private BitmapSource? _imageSource; + private BitmapSource? _frozenImage; private string _statusText = Res.CalibrationStatusReady; private bool _showWorldCoordinates; + private bool _isLiveView = true; - public CalibrationViewModel(ICalibrationDialogService dialogService) + public CalibrationViewModel(ICalibrationDialogService dialogService, ICalibrationCaptureService? captureService = null) { _dialogService = dialogService; + _captureService = captureService; CalibrationPoints = new ObservableCollection(); LoadImageCommand = new DelegateCommand(LoadImage); @@ -29,6 +33,16 @@ public class CalibrationViewModel : BindableBase .ObservesProperty(() => CalibrationPoints.Count); SaveCalibrationCommand = new DelegateCommand(SaveCalibration); LoadCalibrationCommand = new DelegateCommand(LoadCalibration); + CapturePointCommand = new DelegateCommand(CapturePoint, CanCapturePoint); + DeleteSelectedPointCommand = new DelegateCommand(DeleteSelectedPoint, () => SelectedPoint != null) + .ObservesProperty(() => SelectedPoint); + + // 启动实时预览 + if (_captureService != null) + { + _captureService.LiveImageUpdated += OnLiveImageUpdated; + _captureService.StartLivePreview(); + } } public ObservableCollection CalibrationPoints { get; } @@ -39,6 +53,14 @@ public class CalibrationViewModel : BindableBase set => SetProperty(ref _imageSource, value); } + private BitmapSource? _overlayImage; + /// 叠加层图像(显示检测到的轮廓和中心点) + public BitmapSource? OverlayImage + { + get => _overlayImage; + set => SetProperty(ref _overlayImage, value); + } + public string StatusText { get => _statusText; @@ -51,11 +73,48 @@ public class CalibrationViewModel : BindableBase set => SetProperty(ref _showWorldCoordinates, value); } + public bool IsLiveView + { + get => _isLiveView; + set + { + if (SetProperty(ref _isLiveView, value)) + { + RaisePropertyChanged(nameof(LiveViewButtonText)); + if (value) + { + // 切回实时:恢复实时预览 + _captureService?.StartLivePreview(); + } + else + { + // 切到当前:冻结当前帧 + _frozenImage = _imageSource; + _captureService?.StopLivePreview(); + } + } + } + } + + public string LiveViewButtonText => _isLiveView ? "⏸ 冻结" : "▶ 实时"; + public DelegateCommand LoadImageCommand { get; } public DelegateCommand LoadCsvCommand { get; } public DelegateCommand CalibrateCommand { get; } public DelegateCommand SaveCalibrationCommand { get; } public DelegateCommand LoadCalibrationCommand { get; } + public DelegateCommand CapturePointCommand { get; } + public DelegateCommand DeleteSelectedPointCommand { get; } + + /// 是否支持采集模式(有采集服务注入) + public bool IsCaptureAvailable => _captureService?.IsAvailable == true; + + private CalibrationProcessor.CalibrationPoint? _selectedPoint; + public CalibrationProcessor.CalibrationPoint? SelectedPoint + { + get => _selectedPoint; + set => SetProperty(ref _selectedPoint, value); + } private void LoadImage() { @@ -130,6 +189,59 @@ public class CalibrationViewModel : BindableBase } } + private bool CanCapturePoint() => _captureService?.IsAvailable == true; + + private void CapturePoint() + { + if (_captureService == null) return; + + try + { + var result = _captureService.CaptureCurrentPoint(); + if (result == null) + { + StatusText = "采集失败:未能识别标记点,请确认标记在视野内"; + return; + } + + CalibrationPoints.Add(new CalibrationProcessor.CalibrationPoint + { + PixelX = result.PixelX, + PixelY = result.PixelY, + WorldX = result.WorldX, + WorldY = result.WorldY + }); + + // 更新图像显示 + if (result.Image != null) + { + ImageSource = result.Image; + } + + // 绘制检测结果叠加层(轮廓 + 中心点) + DrawDetectionOverlay(result); + + StatusText = $"已采集第 {CalibrationPoints.Count} 个点: 像素({result.PixelX:F1}, {result.PixelY:F1}) → 物理({result.WorldX:F3}, {result.WorldY:F3})"; + _logger.Information("标定点采集: Pixel=({PixelX:F1}, {PixelY:F1}), World=({WorldX:F3}, {WorldY:F3})", + result.PixelX, result.PixelY, result.WorldX, result.WorldY); + } + catch (Exception ex) + { + StatusText = $"采集异常: {ex.Message}"; + _logger.Error(ex, "标定点采集失败"); + } + } + + private void DeleteSelectedPoint() + { + if (SelectedPoint != null && CalibrationPoints.Contains(SelectedPoint)) + { + CalibrationPoints.Remove(SelectedPoint); + SelectedPoint = null; + StatusText = $"已删除,剩余 {CalibrationPoints.Count} 个标定点"; + } + } + public PointF ConvertPixelToWorld(PointF pixel) => _calibrator.PixelToWorld(pixel); public Image? CurrentImage => _currentImage; @@ -138,6 +250,82 @@ public class CalibrationViewModel : BindableBase private void RaiseEvent(EventHandler? handler) => handler?.Invoke(this, EventArgs.Empty); + private void OnLiveImageUpdated(object? sender, LiveImageEventArgs e) + { + if (!_isLiveView) return; + System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() => + { + ImageSource = e.Image; + }); + } + + /// + /// 绘制检测结果叠加层(轮廓 + 中心十字 + 坐标文字) + /// + private void DrawDetectionOverlay(CaptureResult result) + { + if (result.Image == null) return; + + int w = result.Image.PixelWidth; + int h = result.Image.PixelHeight; + + // 创建透明叠加层 + var visual = new System.Windows.Media.DrawingVisual(); + using (var dc = visual.RenderOpen()) + { + // 绘制轮廓 + if (result.ContourPoints != null && result.ContourPoints.Length > 2) + { + var pen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Lime, 2); + var geometry = new System.Windows.Media.StreamGeometry(); + using (var ctx = geometry.Open()) + { + ctx.BeginFigure(new System.Windows.Point(result.ContourPoints[0].X, result.ContourPoints[0].Y), false, true); + for (int i = 1; i < result.ContourPoints.Length; i++) + ctx.LineTo(new System.Windows.Point(result.ContourPoints[i].X, result.ContourPoints[i].Y), true, false); + } + geometry.Freeze(); + dc.DrawGeometry(null, pen, geometry); + } + + // 绘制中心十字 + double cx = result.PixelX; + double cy = result.PixelY; + double crossSize = Math.Max(10, Math.Max(w, h) / 80.0); + var crossPen = new System.Windows.Media.Pen(System.Windows.Media.Brushes.Red, 2); + dc.DrawLine(crossPen, new System.Windows.Point(cx - crossSize, cy), new System.Windows.Point(cx + crossSize, cy)); + dc.DrawLine(crossPen, new System.Windows.Point(cx, cy - crossSize), new System.Windows.Point(cx, cy + crossSize)); + + // 绘制坐标文字 + var text = new System.Windows.Media.FormattedText( + $"({result.PixelX:F1}, {result.PixelY:F1})", + System.Globalization.CultureInfo.CurrentCulture, + System.Windows.FlowDirection.LeftToRight, + new System.Windows.Media.Typeface("Segoe UI"), + Math.Max(12, Math.Max(w, h) / 60.0), + System.Windows.Media.Brushes.Yellow, + 1.0); + dc.DrawText(text, new System.Windows.Point(cx + crossSize + 4, cy - text.Height / 2)); + } + + var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap(w, h, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); + rtb.Render(visual); + rtb.Freeze(); + OverlayImage = rtb; + } + + /// + /// 停止实时预览并清理资源(窗口关闭时调用) + /// + public void Cleanup() + { + if (_captureService != null) + { + _captureService.StopLivePreview(); + _captureService.LiveImageUpdated -= OnLiveImageUpdated; + } + } + private static BitmapSource MatToBitmapSource(Mat mat) { using var bitmap = mat.ToBitmap(); diff --git a/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs b/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs new file mode 100644 index 0000000..e7c9fde --- /dev/null +++ b/XplorePlane/Services/Calibration/NavigationCalibrationCaptureService.cs @@ -0,0 +1,245 @@ +using System; +using System.Drawing; +using System.Threading; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Emgu.CV; +using Emgu.CV.CvEnum; +using Emgu.CV.Structure; +using Emgu.CV.Util; +using Serilog; +using XP.Camera; +using XP.Camera.Calibration; +using XP.Hardware.MotionControl.Abstractions; +using XP.Hardware.MotionControl.Abstractions.Enums; + +namespace XplorePlane.Services.Calibration; + +/// +/// 导航相机标定采集服务实现 +/// 读取编码器坐标 + 导航相机拍图 + 图像识别标记中心(亚像素) +/// +public class NavigationCalibrationCaptureService : ICalibrationCaptureService +{ + private static readonly ILogger _logger = Log.ForContext(); + private readonly IMotionSystem _motionSystem; + private readonly ICameraController _navCamera; + private BitmapSource? _lastCapturedImage; + private readonly object _captureLock = new(); + private ManualResetEventSlim? _imageReadyEvent; + private bool _livePreviewActive; + + public event EventHandler? LiveImageUpdated; + + public NavigationCalibrationCaptureService(IMotionSystem motionSystem, ICameraController navCamera) + { + _motionSystem = motionSystem; + _navCamera = navCamera; + } + + public bool IsAvailable => _navCamera.IsConnected; + + public void StartLivePreview() + { + if (!IsAvailable || _livePreviewActive) return; + _livePreviewActive = true; + _navCamera.ImageGrabbed += OnLiveImageGrabbed; + + // 如果相机没在采集,启动采集并触发第一帧 + if (!_navCamera.IsGrabbing) + { + _navCamera.StartGrabbing(); + _navCamera.ExecuteSoftwareTrigger(); + } + + _logger.Information("标定实时预览已启动"); + } + + public void StopLivePreview() + { + _livePreviewActive = false; + _navCamera.ImageGrabbed -= OnLiveImageGrabbed; + _logger.Information("标定实时预览已停止"); + } + + private void OnLiveImageGrabbed(object? sender, ImageGrabbedEventArgs e) + { + if (!_livePreviewActive) return; + var bmp = ConvertToBitmapSource(e); + if (bmp != null) + { + _lastLiveImage = bmp; + LiveImageUpdated?.Invoke(this, new LiveImageEventArgs(bmp)); + } + } + + private volatile BitmapSource? _lastLiveImage; + + public CaptureResult? CaptureCurrentPoint() + { + if (!IsAvailable) + { + _logger.Warning("采集失败:导航相机未连接"); + return null; + } + + // 1. 读取编码器坐标 + var xAxis = _motionSystem.GetLinearAxis(AxisId.StageX); + var yAxis = _motionSystem.GetLinearAxis(AxisId.StageY); + xAxis.UpdateStatus(); + yAxis.UpdateStatus(); + double worldX = xAxis.ActualPosition; + double worldY = yAxis.ActualPosition; + + // 2. 导航相机拍图 + var image = CaptureImage(); + if (image == null) + { + _logger.Warning("采集失败:无法获取导航相机图像"); + return null; + } + + // 3. 图像识别标记中心(亚像素) + var grayImage = BitmapSourceToGray(image); + var detection = DetectMarkerCenter(grayImage); + grayImage.Dispose(); + + if (detection == null) + { + _logger.Warning("采集失败:未能识别标记点"); + return null; + } + + _logger.Information("标定点采集成功: Pixel=({Px:F1}, {Py:F1}), World=({Wx:F3}, {Wy:F3})", + detection.Value.Center.X, detection.Value.Center.Y, worldX, worldY); + + return new CaptureResult + { + PixelX = detection.Value.Center.X, + PixelY = detection.Value.Center.Y, + WorldX = worldX, + WorldY = worldY, + Image = image, + ContourPoints = detection.Value.Contour + }; + } + + public BitmapSource? CaptureImage() + { + if (!_navCamera.IsConnected) return null; + + // 如果实时预览在运行,直接使用最新一帧 + if (_livePreviewActive && _lastLiveImage != null) + return _lastLiveImage; + + lock (_captureLock) + { + _lastCapturedImage = null; + _imageReadyEvent = new ManualResetEventSlim(false); + + void OnImageGrabbed(object? sender, ImageGrabbedEventArgs e) + { + _lastCapturedImage = ConvertToBitmapSource(e); + _imageReadyEvent?.Set(); + } + + _navCamera.ImageGrabbed += OnImageGrabbed; + + try + { + if (!_navCamera.IsGrabbing) + _navCamera.StartGrabbing(); + _navCamera.ExecuteSoftwareTrigger(); + + // 等待图像到达(超时 3 秒) + if (!_imageReadyEvent.Wait(3000)) + { + _logger.Warning("导航相机采集超时"); + return null; + } + + return _lastCapturedImage; + } + finally + { + _navCamera.ImageGrabbed -= OnImageGrabbed; + _imageReadyEvent?.Dispose(); + _imageReadyEvent = null; + } + } + } + + /// + /// 检测图像中标记点的中心(亚像素精度) + /// 支持圆点标记:阈值分割 → 轮廓检测 → 面积过滤 → 亚像素质心 + /// + private (PointF Center, Point[] Contour)? DetectMarkerCenter(Image grayImage) + { + int w = grayImage.Width, h = grayImage.Height; + + // 高斯模糊降噪 + using var blurred = new Image(w, h); + CvInvoke.GaussianBlur(grayImage, blurred, new Size(5, 5), 1.0); + + // Otsu 自动阈值二值化 + using var binary = new Image(w, h); + CvInvoke.Threshold(blurred, binary, 0, 255, ThresholdType.Otsu | ThresholdType.Binary); + + // 轮廓检测 + using var contours = new VectorOfVectorOfPoint(); + using var hierarchy = new Mat(); + CvInvoke.FindContours(binary, contours, hierarchy, RetrType.External, ChainApproxMethod.ChainApproxSimple); + + // 找到面积最大的轮廓(假设标记是视野中最显著的特征) + double maxArea = 0; + int bestIdx = -1; + double minValidArea = w * h * 0.001; // 最小有效面积:图像面积的 0.1% + double maxValidArea = w * h * 0.5; // 最大有效面积:图像面积的 50% + + for (int i = 0; i < contours.Size; i++) + { + double area = CvInvoke.ContourArea(contours[i]); + if (area < minValidArea || area > maxValidArea) continue; + if (area > maxArea) + { + maxArea = area; + bestIdx = i; + } + } + + if (bestIdx < 0) return null; + + // 亚像素质心计算 + var moments = CvInvoke.Moments(contours[bestIdx]); + if (moments.M00 < 1) return null; + + double cx = moments.M10 / moments.M00; + double cy = moments.M01 / moments.M00; + + var contourPoints = contours[bestIdx].ToArray(); + return (new PointF((float)cx, (float)cy), contourPoints); + } + + private static Image BitmapSourceToGray(BitmapSource bmp) + { + var converted = new FormatConvertedBitmap(bmp, PixelFormats.Bgra32, null, 0); + int w = converted.PixelWidth, h = converted.PixelHeight; + int stride = w * 4; + var pixels = new byte[stride * h]; + converted.CopyPixels(pixels, stride, 0); + var gray = new Image(w, h); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int idx = y * stride + x * 4; + gray.Data[y, x, 0] = (byte)(pixels[idx + 2] * 0.299 + pixels[idx + 1] * 0.587 + pixels[idx] * 0.114); + } + return gray; + } + + private static BitmapSource? ConvertToBitmapSource(ImageGrabbedEventArgs e) + { + if (e.Width <= 0 || e.Height <= 0 || e.PixelData == null) return null; + return PixelConverter.ToBitmapSource(e.PixelData, e.Width, e.Height, e.PixelFormat); + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index cb537a3..a653b38 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -676,14 +676,28 @@ namespace XplorePlane.ViewModels }; var calibrationDialogService = new XP.Camera.Calibration.DefaultCalibrationDialogService(); - var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService); + + // 尝试创建采集服务(需要运动系统和导航相机) + XP.Camera.Calibration.ICalibrationCaptureService? captureService = null; + try + { + var motionSystem = Prism.Ioc.ContainerLocator.Current?.Resolve(); + var navCamera = Prism.Ioc.ContainerLocator.Current?.Resolve(); + if (motionSystem != null && navCamera != null) + captureService = new Services.Calibration.NavigationCalibrationCaptureService(motionSystem, navCamera); + } + catch { /* 采集服务不可用时降级为手动模式 */ } + + var calibrationViewModel = new XP.Camera.Calibration.ViewModels.CalibrationViewModel(calibrationDialogService, captureService); var calibrationControl = new XP.Camera.Calibration.Controls.CalibrationControl { DataContext = calibrationViewModel }; calibrationWindow.Content = calibrationControl; - calibrationWindow.ShowDialog(); + calibrationWindow.Closed += (s, e) => calibrationViewModel.Cleanup(); + calibrationWindow.Owner = System.Windows.Application.Current.MainWindow; + calibrationWindow.Show(); } private void ExecuteOpenSettings()