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()