Files
HawkeyeVision/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs
EugeneTes 8704d0b5ac 2.0.15
profile influences configuration
2026-05-15 11:54:36 +02:00

438 lines
14 KiB
C#

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<CellShape>();
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();
}
}