347 lines
12 KiB
C#
347 lines
12 KiB
C#
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 ICalibrationCaptureService? _captureService;
|
|
private readonly CalibrationProcessor _calibrator = new();
|
|
private Image<Bgr, byte>? _currentImage;
|
|
private static readonly ILogger _logger = Log.ForContext<CalibrationViewModel>();
|
|
private BitmapSource? _imageSource;
|
|
private BitmapSource? _frozenImage;
|
|
private string _statusText = Res.CalibrationStatusReady;
|
|
private bool _showWorldCoordinates;
|
|
private bool _isLiveView = true;
|
|
|
|
public CalibrationViewModel(ICalibrationDialogService dialogService, ICalibrationCaptureService? captureService = null)
|
|
{
|
|
_dialogService = dialogService;
|
|
_captureService = captureService;
|
|
CalibrationPoints = new ObservableCollection<CalibrationProcessor.CalibrationPoint>();
|
|
|
|
LoadImageCommand = new DelegateCommand(LoadImage);
|
|
LoadCsvCommand = new DelegateCommand(LoadCsv);
|
|
CalibrateCommand = new DelegateCommand(Calibrate, CanCalibrate)
|
|
.ObservesProperty(() => CalibrationPoints.Count);
|
|
SaveCalibrationCommand = new DelegateCommand(SaveCalibration);
|
|
LoadCalibrationCommand = new DelegateCommand(LoadCalibration);
|
|
CapturePointCommand = new DelegateCommand(CapturePoint, CanCapturePoint);
|
|
DeleteSelectedPointCommand = new DelegateCommand(DeleteSelectedPoint, () => SelectedPoint != null)
|
|
.ObservesProperty(() => SelectedPoint);
|
|
|
|
// 启动实时预览
|
|
if (_captureService != null)
|
|
{
|
|
_captureService.LiveImageUpdated += OnLiveImageUpdated;
|
|
_captureService.StartLivePreview();
|
|
}
|
|
}
|
|
|
|
public ObservableCollection<CalibrationProcessor.CalibrationPoint> CalibrationPoints { get; }
|
|
|
|
public BitmapSource? ImageSource
|
|
{
|
|
get => _imageSource;
|
|
set => SetProperty(ref _imageSource, value);
|
|
}
|
|
|
|
private BitmapSource? _overlayImage;
|
|
/// <summary>叠加层图像(显示检测到的轮廓和中心点)</summary>
|
|
public BitmapSource? OverlayImage
|
|
{
|
|
get => _overlayImage;
|
|
set => SetProperty(ref _overlayImage, value);
|
|
}
|
|
|
|
public string StatusText
|
|
{
|
|
get => _statusText;
|
|
set => SetProperty(ref _statusText, value);
|
|
}
|
|
|
|
public bool ShowWorldCoordinates
|
|
{
|
|
get => _showWorldCoordinates;
|
|
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; }
|
|
|
|
/// <summary>是否支持采集模式(有采集服务注入)</summary>
|
|
public bool IsCaptureAvailable => _captureService?.IsAvailable == true;
|
|
|
|
private CalibrationProcessor.CalibrationPoint? _selectedPoint;
|
|
public CalibrationProcessor.CalibrationPoint? SelectedPoint
|
|
{
|
|
get => _selectedPoint;
|
|
set => SetProperty(ref _selectedPoint, value);
|
|
}
|
|
|
|
private void LoadImage()
|
|
{
|
|
_logger.Information("Loading image file");
|
|
var fileName = _dialogService.ShowOpenFileDialog("图像文件|*.jpg;*.png;*.bmp;*.tif");
|
|
if (fileName == null) return;
|
|
|
|
_currentImage = new Image<Bgr, byte>(fileName);
|
|
ImageSource = MatToBitmapSource(_currentImage.Mat);
|
|
StatusText = string.Format(Res.CalibrationStatusImageLoaded, fileName);
|
|
RaiseEvent(ImageLoadedRequested);
|
|
}
|
|
|
|
private void LoadCsv()
|
|
{
|
|
var fileName = _dialogService.ShowOpenFileDialog("CSV文件|*.csv|所有文件|*.*");
|
|
if (fileName == null) return;
|
|
|
|
var points = _calibrator.LoadPointsFromCsv(fileName);
|
|
CalibrationPoints.Clear();
|
|
foreach (var pt in points)
|
|
CalibrationPoints.Add(pt);
|
|
|
|
StatusText = string.Format(Res.CalibrationStatusCsvLoaded, CalibrationPoints.Count, fileName);
|
|
}
|
|
|
|
private bool CanCalibrate() => CalibrationPoints.Count >= 4;
|
|
|
|
private void Calibrate()
|
|
{
|
|
if (CalibrationPoints.Count < 4)
|
|
{
|
|
_dialogService.ShowError(Res.CalibrationErrorMinPoints, Res.CalibrationSuccessTitle);
|
|
return;
|
|
}
|
|
|
|
if (_calibrator.Calibrate(new List<CalibrationProcessor.CalibrationPoint>(CalibrationPoints)))
|
|
{
|
|
StatusText = string.Format(Res.CalibrationStatusSuccess, CalibrationPoints.Count);
|
|
_dialogService.ShowInfo(Res.CalibrationSuccessMessage, Res.CalibrationSuccessTitle);
|
|
}
|
|
else
|
|
{
|
|
StatusText = Res.CalibrationStatusFailed;
|
|
_dialogService.ShowError(Res.CalibrationStatusFailed, Res.CalibrationSuccessTitle);
|
|
}
|
|
}
|
|
|
|
private void SaveCalibration()
|
|
{
|
|
var fileName = _dialogService.ShowSaveFileDialog("标定文件|*.json", "calibration.json");
|
|
if (fileName == null) return;
|
|
|
|
_calibrator.SaveCalibration(fileName, new List<CalibrationProcessor.CalibrationPoint>(CalibrationPoints));
|
|
StatusText = string.Format(Res.CalibrationStatusSaved, fileName);
|
|
_dialogService.ShowInfo(Res.CalibrationSaveSuccess, Res.CalibrationSuccessTitle);
|
|
}
|
|
|
|
private void LoadCalibration()
|
|
{
|
|
var fileName = _dialogService.ShowOpenFileDialog("标定文件|*.json");
|
|
if (fileName == null) return;
|
|
|
|
if (_calibrator.LoadCalibration(fileName))
|
|
{
|
|
StatusText = string.Format(Res.CalibrationStatusLoaded, fileName);
|
|
_dialogService.ShowInfo(Res.CalibrationLoadSuccess, Res.CalibrationSuccessTitle);
|
|
}
|
|
else
|
|
{
|
|
_dialogService.ShowError(Res.CalibrationLoadFailed, Res.CalibrationSuccessTitle);
|
|
}
|
|
}
|
|
|
|
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<Bgr, byte>? CurrentImage => _currentImage;
|
|
|
|
public event EventHandler? ImageLoadedRequested;
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 绘制检测结果叠加层(轮廓 + 中心十字 + 坐标文字)
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 停止实时预览并清理资源(窗口关闭时调用)
|
|
/// </summary>
|
|
public void Cleanup()
|
|
{
|
|
if (_captureService != null)
|
|
{
|
|
_captureService.StopLivePreview();
|
|
_captureService.LiveImageUpdated -= OnLiveImageUpdated;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|