using System.ComponentModel; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LindtLeerformPlugin.Models; using LindtLeerformPlugin.Services; using LindtLeerformPlugin.Views; using OpenCvSharp; using Serilog; using VisionBuilder.UI.Common; using VisionBuilder.UI.Common.Processing; namespace LindtLeerformPlugin.ViewModels; public partial class CalibrationWindowViewModel : ObservableObject, IDisposable { private readonly IImageSource _imageSource; private readonly LeerformSettings _settings; private readonly SingleCameraVM _singleCameraVm; private readonly LeerformPatternRecognitionService _patternService; private readonly LeerformRecipeStore _recipeStore; private CancellationTokenSource? _previewCts; private Mat? _lastFrame; private Mat? _calibCameraMatrix; private Mat? _calibDistCoeffs; private Mat? _referenceFrame; // Non-modal cell histogram tool window state. While open, clicks on cells in the // calibration preview update its content instead of opening a new window. private CellHistogramWindow? _cellDialog; private CellHistogramViewModel? _cellDialogVm; // Transient seed for the average spline; never persisted in the recipe. private float[]? _averageReferenceHistogram; private float[]? _averageSpline; private readonly Dictionary _cellSplines = new(); [ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage; [ObservableProperty] private string _statusText = "Ready"; [ObservableProperty] private int _capturedImageCount; [ObservableProperty] private bool _isPreviewRunning; [ObservableProperty] private bool _isCalibrated; [ObservableProperty] private bool _applyCalibration; [ObservableProperty] private string _calibrationImageDirectory; [ObservableProperty] private double _rmsError; // Pattern [ObservableProperty] private decimal? _rows = 4m; [ObservableProperty] private decimal? _cols = 5m; [ObservableProperty] private CellShape _selectedCellShape = CellShape.Round; [ObservableProperty] private decimal? _padding = 5m; // ROI [ObservableProperty] private decimal? _roiX = 0m; [ObservableProperty] private decimal? _roiY = 0m; [ObservableProperty] private decimal? _roiWidth = 0m; [ObservableProperty] private decimal? _roiHeight = 0m; // Detection [ObservableProperty] private bool _hasAverageSpline; // Recipe [ObservableProperty] private string _currentRecipeName = "default"; public CellShape[] AvailableCellShapes { get; } = Enum.GetValues(); public CalibrationWindowViewModel( IImageSource imageSource, LeerformSettings settings, SingleCameraVM singleCameraVm, LeerformPatternRecognitionService patternService, LeerformRecipeStore recipeStore) { _imageSource = imageSource; _settings = settings; _singleCameraVm = singleCameraVm; _patternService = patternService; _recipeStore = recipeStore; _calibrationImageDirectory = settings.CalibrationImageDirectory; var calibrationData = CalibrationDataStore.Load(); _isCalibrated = calibrationData is { CameraMatrix.Length: > 0 }; _rmsError = calibrationData?.RmsError ?? 0; if (_isCalibrated) LoadCalibrationMats(calibrationData!); UpdateCapturedImageCount(); _currentRecipeName = _singleCameraVm.SelectedRecipe?.RecipeName ?? "default"; _singleCameraVm.PropertyChanged += OnSingleCameraVmPropertyChanged; TryLoadRecipe(_currentRecipeName); } private void OnSingleCameraVmPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName != nameof(SingleCameraVM.SelectedRecipe)) return; var name = _singleCameraVm.SelectedRecipe?.RecipeName ?? "default"; Dispatcher.UIThread.Post(() => { CurrentRecipeName = name; TryLoadRecipe(name); }); } private void UpdateCapturedImageCount() { if (Directory.Exists(CalibrationImageDirectory)) CapturedImageCount = Directory.GetFiles(CalibrationImageDirectory, "*.png").Length; else CapturedImageCount = 0; } [RelayCommand] private void StartPreview() { if (IsPreviewRunning) return; IsPreviewRunning = true; _previewCts = new CancellationTokenSource(); _ = PreviewLoopAsync(_previewCts.Token); StatusText = "Preview running"; } [RelayCommand] private void StopPreview() { if (!IsPreviewRunning) return; _previewCts?.Cancel(); IsPreviewRunning = false; StatusText = "Preview stopped"; } [RelayCommand] private void CaptureImage() { if (_lastFrame == null || _lastFrame.Empty()) return; Directory.CreateDirectory(CalibrationImageDirectory); var filename = $"calib_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; var path = Path.Combine(CalibrationImageDirectory, filename); Cv2.ImWrite(path, _lastFrame); UpdateCapturedImageCount(); StatusText = $"Captured: {filename}"; } [RelayCommand] private void RunCalibration() { try { StatusText = "Calibrating..."; var patternSize = new Size(_settings.CheckerboardCols, _settings.CheckerboardRows); var data = CameraCalibrationService.Calibrate(CalibrationImageDirectory, patternSize); CalibrationDataStore.Save(data); _settings.CalibrationImageDirectory = CalibrationImageDirectory; LoadCalibrationMats(data); IsCalibrated = true; RmsError = data.RmsError; StatusText = $"Calibrated (RMS: {data.RmsError:F4})"; } catch (Exception ex) { StatusText = $"Calibration error: {ex.Message}"; } } [RelayCommand] private void ClearCalibrationImages() { if (!Directory.Exists(CalibrationImageDirectory)) return; foreach (var file in Directory.GetFiles(CalibrationImageDirectory, "*.png")) File.Delete(file); UpdateCapturedImageCount(); StatusText = "Calibration images cleared"; } [RelayCommand] private void SetReferenceHistogram() { using var frame = GetAnalysisFrame(); if (frame == null) { StatusText = "No active frame to use as reference"; return; } try { var pattern = BuildCurrentPattern(); _averageReferenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); _averageSpline = SplineCurve.CreateDefault(_averageReferenceHistogram); _cellSplines.Clear(); _referenceFrame?.Dispose(); _referenceFrame = frame.Clone(); HasAverageSpline = true; StatusText = "Average spline seeded; per-cell overrides cleared."; RefreshPreview(); } catch (Exception ex) { Log.Error(ex, "Failed to seed average spline"); StatusText = $"Reference error: {ex.Message}"; } } [RelayCommand] private void TestPattern() => RunTestPattern(); private void RunTestPattern() { using var frame = GetAnalysisFrame(); if (frame == null) { StatusText = "No active frame to test"; return; } if (_averageSpline == null) { StatusText = "Set reference first"; return; } try { var recipe = BuildCurrentRecipe(); var results = _patternService.EvaluateCells(frame, recipe); var overrides = new HashSet(_cellSplines.Keys); using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results, overrides); var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay); var old = PreviewImage; PreviewImage = bitmap; old?.Dispose(); var bad = results.Count(r => !r.IsGood); StatusText = $"Test: {results.Count - bad} good / {bad} bad"; } catch (Exception ex) { Log.Error(ex, "Failed to test pattern"); StatusText = $"Test error: {ex.Message}"; } } private Mat? GetAnalysisFrame() { if (_lastFrame == null || _lastFrame.Empty()) return null; var frame = new Mat(); if (_calibCameraMatrix != null && _calibDistCoeffs != null) Cv2.Undistort(_lastFrame, frame, _calibCameraMatrix, _calibDistCoeffs); else _lastFrame.CopyTo(frame); return frame; } public int? HitTestCell(int imageX, int imageY) { return _patternService.FindCellAtPoint(imageX, imageY, BuildCurrentPattern()); } public Task ShowCellHistogramAsync(int imageX, int imageY, Avalonia.Controls.Window owner) { using var frame = GetAnalysisFrame(); if (frame == null) return Task.CompletedTask; var pattern = BuildCurrentPattern(); var cellIndex = _patternService.FindCellAtPoint(imageX, imageY, pattern); if (cellIndex == null) return Task.CompletedTask; if (_averageSpline is not { Length: SplineCurve.KnotCount }) { StatusText = "Set reference first"; return Task.CompletedTask; } try { // Already open: commit the previous cell's edits and switch to the new cell. if (_cellDialog != null && _cellDialogVm != null) { CommitDialogStateToCell(_cellDialogVm); LoadCellIntoDialog(_cellDialogVm, frame, pattern, cellIndex.Value); _cellDialog.Activate(); RunTestPattern(); return Task.CompletedTask; } var dialogVm = new CellHistogramViewModel { Bins = new[] { 5, 5, 5 } }; LoadCellIntoDialog(dialogVm, frame, pattern, cellIndex.Value); // Re-test against the current frame on every spline drag release so the // calibration window's preview updates live behind the open dialog. dialogVm.DragCompleted = (avg, cell) => { if (avg is { Length: SplineCurve.KnotCount }) _averageSpline = avg; if (cell is { Length: SplineCurve.KnotCount }) _cellSplines[dialogVm.CellIndex] = cell; else _cellSplines.Remove(dialogVm.CellIndex); RunTestPattern(); }; _cellDialogVm = dialogVm; _cellDialog = new CellHistogramWindow { DataContext = dialogVm }; _cellDialog.Closed += OnCellDialogClosed; _cellDialog.Show(owner); } catch (Exception ex) { Log.Error(ex, "Failed to show cell histogram"); StatusText = $"Histogram error: {ex.Message}"; } return Task.CompletedTask; } private void LoadCellIntoDialog(CellHistogramViewModel vm, Mat frame, CellPattern pattern, int cellIndex) { var bins = vm.Bins is { Length: 3 } ? vm.Bins : new[] { 5, 5, 5 }; var cellHist = _patternService.ComputeCellHistogram(frame, pattern, cellIndex, bins); var avgSplineCopy = (float[])(_averageSpline ?? new float[SplineCurve.KnotCount]).Clone(); var cellSplineCopy = _cellSplines.TryGetValue(cellIndex, out var existing) && existing is { Length: SplineCurve.KnotCount } ? (float[])existing.Clone() : null; vm.CellIndex = cellIndex; vm.Title = $"Cell #{cellIndex}"; vm.CellHistogram = cellHist; vm.AverageSpline = avgSplineCopy; vm.CellSpline = cellSplineCopy; } private void CommitDialogStateToCell(CellHistogramViewModel vm) { if (vm.AverageSpline is { Length: SplineCurve.KnotCount }) _averageSpline = vm.AverageSpline; if (vm.CellSpline is { Length: SplineCurve.KnotCount }) _cellSplines[vm.CellIndex] = vm.CellSpline; else _cellSplines.Remove(vm.CellIndex); } private void OnCellDialogClosed(object? sender, EventArgs e) { if (_cellDialogVm != null) CommitDialogStateToCell(_cellDialogVm); if (_cellDialog != null) _cellDialog.Closed -= OnCellDialogClosed; _cellDialog = null; _cellDialogVm = null; RunTestPattern(); } [RelayCommand] private void SaveRecipe() { try { var recipe = BuildCurrentRecipe(); recipe.RecipeName = CurrentRecipeName; if (_referenceFrame != null && !_referenceFrame.Empty()) { using var thumb = new Mat(); Cv2.Resize(_referenceFrame, thumb, new Size(128, 128), 0, 0, InterpolationFlags.Area); var bytes = thumb.ImEncode(".png"); recipe.ThumbnailBase64 = Convert.ToBase64String(bytes); } _recipeStore.Save(recipe); StatusText = $"Saved recipe '{recipe.RecipeName}'"; } catch (Exception ex) { Log.Error(ex, "Failed to save recipe"); StatusText = $"Save error: {ex.Message}"; } } private void TryLoadRecipe(string recipeName) { try { var recipe = _recipeStore.Load(recipeName); if (recipe == null) return; Rows = recipe.Pattern.Rows; Cols = recipe.Pattern.Cols; SelectedCellShape = recipe.Pattern.Shape; Padding = recipe.Pattern.Padding; RoiX = recipe.Pattern.RoiX; RoiY = recipe.Pattern.RoiY; RoiWidth = recipe.Pattern.RoiWidth; RoiHeight = recipe.Pattern.RoiHeight; _averageReferenceHistogram = null; _averageSpline = recipe.AverageSpline is { Length: SplineCurve.KnotCount } ? recipe.AverageSpline : null; _cellSplines.Clear(); if (recipe.CellSplines != null) { foreach (var kv in recipe.CellSplines) { if (kv.Value is { Length: SplineCurve.KnotCount }) _cellSplines[kv.Key] = kv.Value; } } HasAverageSpline = _averageSpline != null; _referenceFrame?.Dispose(); _referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe); StatusText = HasAverageSpline ? $"Loaded recipe '{recipeName}'" : $"Loaded recipe '{recipeName}' — no spline; click 'Set Reference'."; } catch (Exception ex) { Log.Warning(ex, "Failed to load recipe '{Recipe}'", recipeName); } } private CellPattern BuildCurrentPattern() => new() { Rows = (int)(Rows ?? 1m), Cols = (int)(Cols ?? 1m), Shape = SelectedCellShape, Padding = (int)(Padding ?? 0m), RoiX = (int)(RoiX ?? 0m), RoiY = (int)(RoiY ?? 0m), RoiWidth = (int)(RoiWidth ?? 0m), RoiHeight = (int)(RoiHeight ?? 0m) }; private LeerformRecipe BuildCurrentRecipe() => new() { RecipeName = CurrentRecipeName, Pattern = BuildCurrentPattern(), HistogramBins = [5, 5, 5], AverageSpline = _averageSpline ?? [], CellSplines = new Dictionary(_cellSplines) }; private async Task PreviewLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var frame = await _imageSource.GetImage(ct); _lastFrame?.Dispose(); _lastFrame = frame; if ((RoiWidth ?? 0m) <= 0m || (RoiHeight ?? 0m) <= 0m) { var w = frame.Cols; var h = frame.Rows; await Dispatcher.UIThread.InvokeAsync(() => { RoiX = 0; RoiY = 0; RoiWidth = w; RoiHeight = h; }); } var bitmap = RenderFrame(frame); await Dispatcher.UIThread.InvokeAsync(() => { var old = PreviewImage; PreviewImage = bitmap; old?.Dispose(); }); } catch (OperationCanceledException) { break; } catch (Exception ex) { await Dispatcher.UIThread.InvokeAsync(() => StatusText = $"Preview error: {ex.Message}"); break; } } } private static readonly Scalar GridColor = new(0, 255, 255); // BGR yellow private Avalonia.Media.Imaging.Bitmap RenderFrame(Mat frame) { var displayFrame = frame; var owned = false; if (ApplyCalibration && _calibCameraMatrix != null && _calibDistCoeffs != null) { displayFrame = new Mat(); Cv2.Undistort(frame, displayFrame, _calibCameraMatrix, _calibDistCoeffs); owned = true; } var pattern = BuildCurrentPattern(); if (pattern.Rows > 0 && pattern.Cols > 0 && pattern.RoiWidth > 0 && pattern.RoiHeight > 0) { if (!owned) { displayFrame = frame.Clone(); owned = true; } _patternService.DrawPattern(displayFrame, pattern, GridColor); if (_cellSplines.Count > 0) { var overrides = new HashSet(_cellSplines.Keys); _patternService.DrawOverrideMarkers(displayFrame, pattern, overrides); } } var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame); if (owned) displayFrame.Dispose(); return bitmap; } private void RefreshPreview() { if (_lastFrame == null || _lastFrame.Empty()) return; try { var bitmap = RenderFrame(_lastFrame); var old = PreviewImage; PreviewImage = bitmap; old?.Dispose(); } catch (Exception ex) { StatusText = $"Preview error: {ex.Message}"; } } /// /// Pattern geometry changed — cell indices are no longer valid, so any per-cell /// overrides and the seeded average spline must be discarded. The user has to /// click 'Set Reference' again before testing. /// private void InvalidateSplinesForPatternChange() { if (_cellSplines.Count == 0 && _averageSpline == null) return; _cellSplines.Clear(); _averageSpline = null; _averageReferenceHistogram = null; HasAverageSpline = false; StatusText = "Pattern changed — splines cleared. Click 'Set Reference'."; } partial void OnApplyCalibrationChanged(bool value) => RefreshPreview(); partial void OnRowsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } partial void OnColsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } partial void OnSelectedCellShapeChanged(CellShape value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } partial void OnPaddingChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } partial void OnRoiXChanged(decimal? value) => RefreshPreview(); partial void OnRoiYChanged(decimal? value) => RefreshPreview(); partial void OnRoiWidthChanged(decimal? value) => RefreshPreview(); partial void OnRoiHeightChanged(decimal? value) => RefreshPreview(); private void LoadCalibrationMats(Models.CalibrationData data) { _calibCameraMatrix?.Dispose(); _calibDistCoeffs?.Dispose(); (_calibCameraMatrix, _calibDistCoeffs) = CameraCalibrationService.LoadCalibrationMats(data); } public void Dispose() { _singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged; if (_cellDialog != null) { _cellDialog.Closed -= OnCellDialogClosed; _cellDialog.Close(); _cellDialog = null; _cellDialogVm = null; } StopPreview(); _lastFrame?.Dispose(); _referenceFrame?.Dispose(); _calibCameraMatrix?.Dispose(); _calibDistCoeffs?.Dispose(); } }