using System.ComponentModel; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LindtLeerformPlugin.Models; using LindtLeerformPlugin.Services; 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; [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 thresholds (LAB-based, see LeerformPatternRecognitionService.DetectChocolateMask) [ObservableProperty] private decimal? _aThreshold = 140m; [ObservableProperty] private decimal? _lThreshold = 100m; [ObservableProperty] private decimal? _minBlobArea = 100m; [ObservableProperty] private decimal? _maxBlobArea = 5000m; // 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 TestPattern() => RunTestPattern(); private void RunTestPattern() { using var frame = GetAnalysisFrame(); if (frame == null) { StatusText = "No active frame to test"; return; } try { var recipe = BuildCurrentRecipe(); var results = _patternService.EvaluateCells(frame, recipe); using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results); var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay); var old = PreviewImage; PreviewImage = bitmap; old?.Dispose(); _referenceFrame?.Dispose(); _referenceFrame = frame.Clone(); 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; } [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; AThreshold = recipe.AThreshold; LThreshold = recipe.LThreshold; MinBlobArea = recipe.MinBlobArea; MaxBlobArea = recipe.MaxBlobArea; _referenceFrame?.Dispose(); _referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe); StatusText = $"Loaded recipe '{recipeName}'"; } 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(), AThreshold = (int)(AThreshold ?? 140m), LThreshold = (int)(LThreshold ?? 100m), MinBlobArea = (int)(MinBlobArea ?? 100m), MaxBlobArea = (int)(MaxBlobArea ?? 5000m) }; 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); } 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}"; } } partial void OnApplyCalibrationChanged(bool value) => RefreshPreview(); partial void OnRowsChanged(decimal? value) => RefreshPreview(); partial void OnColsChanged(decimal? value) => RefreshPreview(); partial void OnSelectedCellShapeChanged(CellShape value) => RefreshPreview(); partial void OnPaddingChanged(decimal? value) => 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(); partial void OnAThresholdChanged(decimal? value) => RefreshPreview(); partial void OnLThresholdChanged(decimal? value) => RefreshPreview(); partial void OnMinBlobAreaChanged(decimal? value) => RefreshPreview(); partial void OnMaxBlobAreaChanged(decimal? value) => RefreshPreview(); private void LoadCalibrationMats(Models.CalibrationData data) { _calibCameraMatrix?.Dispose(); _calibDistCoeffs?.Dispose(); (_calibCameraMatrix, _calibDistCoeffs) = CameraCalibrationService.LoadCalibrationMats(data); } public void Dispose() { _singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged; StopPreview(); _lastFrame?.Dispose(); _referenceFrame?.Dispose(); _calibCameraMatrix?.Dispose(); _calibDistCoeffs?.Dispose(); } }