diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0ee5e76..53fc8b0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(find:*)", "Bash(ls:*)", - "Bash(dotnet sln:*)" + "Bash(dotnet sln:*)", + "Bash(dotnet build:*)" ] } } diff --git a/Plugins/LindtLeerformPlugin/LeerformModule.cs b/Plugins/LindtLeerformPlugin/LeerformModule.cs index dfb9f83..9356c2e 100644 --- a/Plugins/LindtLeerformPlugin/LeerformModule.cs +++ b/Plugins/LindtLeerformPlugin/LeerformModule.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using LindtLeerformPlugin.Services; using LindtLeerformPlugin.ViewModels; using LindtLeerformPlugin.Views; using Serilog; @@ -15,12 +16,21 @@ public class LeerformModule : IVisionBuilderModule private readonly SingleCameraVM _singleCameraVm; private readonly LeerformSettings _settings; private readonly IImageSource _imageSource; + private readonly LeerformPatternRecognitionService _patternService; + private readonly LeerformRecipeStore _recipeStore; - public LeerformModule(SingleCameraVM singleCameraVm, LeerformSettings settings, IImageSource imageSource) + public LeerformModule( + SingleCameraVM singleCameraVm, + LeerformSettings settings, + IImageSource imageSource, + LeerformPatternRecognitionService patternService, + LeerformRecipeStore recipeStore) { _singleCameraVm = singleCameraVm; _settings = settings; _imageSource = imageSource; + _patternService = patternService; + _recipeStore = recipeStore; } public void InitializeModule() @@ -45,7 +55,7 @@ public class LeerformModule : IVisionBuilderModule } var parent = desktop.MainWindow; - var vm = new CalibrationWindowViewModel(_imageSource, _settings); + var vm = new CalibrationWindowViewModel(_imageSource, _settings, _singleCameraVm, _patternService, _recipeStore); var window = new CalibrationWindow { DataContext = vm }; if (parent != null) diff --git a/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs b/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs index fd370f5..e84491b 100644 --- a/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs +++ b/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs @@ -10,20 +10,40 @@ namespace LindtLeerformPlugin; public class LeerformRecognitionControl : BaseRecognitionControl { private readonly IImageSource _imageSource; + private readonly LeerformPatternRecognitionService _patternService; + private readonly LeerformRecipeStore _recipeStore; private Mat? _cameraMatrix; private Mat? _distCoeffs; public LeerformRecognitionControl( LeerformRecognitionControlSettings settings, IImageSource imageSource, - ILoadingService loadingService) : base(settings, loadingService) + ILoadingService loadingService, + LeerformPatternRecognitionService patternService, + LeerformRecipeStore recipeStore) : base(settings, loadingService) { _imageSource = imageSource; + _patternService = patternService; + _recipeStore = recipeStore; } public override List GetRecipesData() { - return [new RecipeData { RecipeName = "Default" }]; + var names = _recipeStore.ListRecipeNames(); + if (names.Count == 0) + return [new RecipeData { RecipeName = "Default" }]; + + var recipes = new List(names.Count); + foreach (var name in names) + { + var recipe = _recipeStore.Load(name); + recipes.Add(new RecipeData + { + RecipeName = name, + Image = recipe != null ? LeerformRecipeStore.DecodeThumbnail(recipe) : null + }); + } + return recipes; } protected override void Initialize(RecipeData currentRecipe) diff --git a/Plugins/LindtLeerformPlugin/Models/CellPattern.cs b/Plugins/LindtLeerformPlugin/Models/CellPattern.cs new file mode 100644 index 0000000..7144a88 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Models/CellPattern.cs @@ -0,0 +1,13 @@ +namespace LindtLeerformPlugin.Models; + +public class CellPattern +{ + public int Rows { get; set; } = 4; + public int Cols { get; set; } = 5; + public CellShape Shape { get; set; } = CellShape.Round; + public int Padding { get; set; } = 5; + public int RoiX { get; set; } + public int RoiY { get; set; } + public int RoiWidth { get; set; } + public int RoiHeight { get; set; } +} diff --git a/Plugins/LindtLeerformPlugin/Models/CellRegion.cs b/Plugins/LindtLeerformPlugin/Models/CellRegion.cs new file mode 100644 index 0000000..2bb9d9a --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Models/CellRegion.cs @@ -0,0 +1,13 @@ +using OpenCvSharp; + +namespace LindtLeerformPlugin.Models; + +public class CellRegion +{ + public int Index { get; set; } + public int Row { get; set; } + public int Col { get; set; } + public Rect BoundingBox { get; set; } + public Point Center { get; set; } + public int Radius { get; set; } +} diff --git a/Plugins/LindtLeerformPlugin/Models/CellResult.cs b/Plugins/LindtLeerformPlugin/Models/CellResult.cs new file mode 100644 index 0000000..f4da47b --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Models/CellResult.cs @@ -0,0 +1,8 @@ +namespace LindtLeerformPlugin.Models; + +public class CellResult +{ + public int Index { get; set; } + public bool IsGood { get; set; } + public double Distance { get; set; } +} diff --git a/Plugins/LindtLeerformPlugin/Models/CellShape.cs b/Plugins/LindtLeerformPlugin/Models/CellShape.cs new file mode 100644 index 0000000..76df869 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Models/CellShape.cs @@ -0,0 +1,7 @@ +namespace LindtLeerformPlugin.Models; + +public enum CellShape +{ + Square, + Round +} diff --git a/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs new file mode 100644 index 0000000..0b2c709 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs @@ -0,0 +1,11 @@ +namespace LindtLeerformPlugin.Models; + +public class LeerformRecipe +{ + public string RecipeName { get; set; } = string.Empty; + public CellPattern Pattern { get; set; } = new(); + public int[] HistogramBins { get; set; } = [5, 5, 5]; + public float[] ReferenceHistogram { get; set; } = []; + public double Tolerance { get; set; } = 0.30; + public string ThumbnailBase64 { get; set; } = string.Empty; +} diff --git a/Plugins/LindtLeerformPlugin/Plugin.cs b/Plugins/LindtLeerformPlugin/Plugin.cs index 60e594a..c5572e2 100644 --- a/Plugins/LindtLeerformPlugin/Plugin.cs +++ b/Plugins/LindtLeerformPlugin/Plugin.cs @@ -1,3 +1,4 @@ +using LindtLeerformPlugin.Services; using Ninject; using VisionBuilder.UI.Common; using VisionBuilder.UI.Common.Plugins; @@ -25,6 +26,10 @@ public class Plugin : IPlugin kernel.Bind() .ToConstant(new LeerformRecognitionControlSettings(cameraName)); + + kernel.Bind().ToSelf().InSingletonScope(); + kernel.Bind().ToSelf().InSingletonScope(); + kernel.Rebind() .To() .InSingletonScope(); diff --git a/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs new file mode 100644 index 0000000..20a6751 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs @@ -0,0 +1,258 @@ +using LindtLeerformPlugin.Models; +using OpenCvSharp; + +namespace LindtLeerformPlugin.Services; + +public class LeerformPatternRecognitionService +{ + private static readonly Scalar GoodColor = new(0, 255, 0); // BGR Green + private static readonly Scalar BadColor = new(0, 0, 255); // BGR Red + + public IReadOnlyList ComputeCells(CellPattern pattern) + { + var cells = new List(); + if (pattern.Rows <= 0 || pattern.Cols <= 0 || pattern.RoiWidth <= 0 || pattern.RoiHeight <= 0) + return cells; + + var cellW = pattern.RoiWidth / pattern.Cols; + var cellH = pattern.RoiHeight / pattern.Rows; + var padding = Math.Max(0, pattern.Padding); + + var index = 0; + for (var r = 0; r < pattern.Rows; r++) + { + for (var c = 0; c < pattern.Cols; c++) + { + var x = pattern.RoiX + c * cellW + padding; + var y = pattern.RoiY + r * cellH + padding; + var w = Math.Max(1, cellW - 2 * padding); + var h = Math.Max(1, cellH - 2 * padding); + var rect = new Rect(x, y, w, h); + var center = new Point(x + w / 2, y + h / 2); + var radius = Math.Max(1, Math.Min(w, h) / 2); + + cells.Add(new CellRegion + { + Index = index++, + Row = r, + Col = c, + BoundingBox = rect, + Center = center, + Radius = radius + }); + } + } + return cells; + } + + public Mat BuildMask(CellPattern pattern, Size imageSize, int? cellIndex = null) + { + var mask = new Mat(imageSize, MatType.CV_8UC1, Scalar.All(0)); + var cells = ComputeCells(pattern); + var color = Scalar.All(255); + var imageRect = new Rect(0, 0, imageSize.Width, imageSize.Height); + + foreach (var cell in cells) + { + if (cellIndex.HasValue && cellIndex.Value != cell.Index) + continue; + + var clipped = cell.BoundingBox & imageRect; + if (clipped.Width <= 0 || clipped.Height <= 0) + continue; + + if (pattern.Shape == CellShape.Square) + { + Cv2.Rectangle(mask, clipped, color, thickness: -1); + } + else + { + Cv2.Circle(mask, cell.Center, cell.Radius, color, thickness: -1); + } + } + return mask; + } + + public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null) + { + bins ??= [5, 5, 5]; + using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows)); + using var hist = CalcHistogram(bgrImage, mask, bins); + return HistogramToArray(hist); + } + + public IReadOnlyList EvaluateCells(Mat bgrImage, LeerformRecipe recipe) + { + var cells = ComputeCells(recipe.Pattern); + var bins = recipe.HistogramBins is { Length: 3 } ? recipe.HistogramBins : [5, 5, 5]; + var imageSize = new Size(bgrImage.Cols, bgrImage.Rows); + var results = new List(cells.Count); + + using var refHist = ArrayToHistogram(recipe.ReferenceHistogram, bins); + + foreach (var cell in cells) + { + using var mask = BuildMask(recipe.Pattern, imageSize, cell.Index); + using var hist = CalcHistogram(bgrImage, mask, bins); + var distance = Cv2.CompareHist(refHist, hist, HistCompMethods.Bhattacharyya); + results.Add(new CellResult + { + Index = cell.Index, + IsGood = distance <= recipe.Tolerance, + Distance = distance + }); + } + + return results; + } + + public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList results) + { + var overlay = bgrImage.Clone(); + var cells = ComputeCells(pattern); + var resultByIndex = results.ToDictionary(r => r.Index); + const int thickness = 2; + + foreach (var cell in cells) + { + if (!resultByIndex.TryGetValue(cell.Index, out var result)) + continue; + + var color = result.IsGood ? GoodColor : BadColor; + + if (pattern.Shape == CellShape.Square) + { + Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness); + } + else + { + Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness); + } + } + return overlay; + } + + public void DrawPattern(Mat target, CellPattern pattern, Scalar color, int thickness = 2) + { + var cells = ComputeCells(pattern); + foreach (var cell in cells) + { + if (pattern.Shape == CellShape.Square) + Cv2.Rectangle(target, cell.BoundingBox, color, thickness); + else + Cv2.Circle(target, cell.Center, cell.Radius, color, thickness); + } + } + + public int? FindCellAtPoint(int x, int y, CellPattern pattern) + { + var cells = ComputeCells(pattern); + foreach (var cell in cells) + { + if (pattern.Shape == CellShape.Square) + { + if (cell.BoundingBox.Contains(x, y)) + return cell.Index; + } + else + { + var dx = x - cell.Center.X; + var dy = y - cell.Center.Y; + if (dx * dx + dy * dy <= cell.Radius * cell.Radius) + return cell.Index; + } + } + return null; + } + + public float[] ComputeCellHistogram(Mat bgrImage, CellPattern pattern, int cellIndex, int[]? bins = null) + { + bins ??= [5, 5, 5]; + using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows), cellIndex); + using var hist = CalcHistogram(bgrImage, mask, bins); + return HistogramToArray(hist); + } + + public double CompareHistograms(float[] reference, float[] candidate, int[]? bins = null) + { + bins ??= [5, 5, 5]; + using var refMat = ArrayToHistogram(reference, bins); + using var candMat = ArrayToHistogram(candidate, bins); + return Cv2.CompareHist(refMat, candMat, HistCompMethods.Bhattacharyya); + } + + public static Mat RenderHistogramChart(float[] histogram, int[] bins, int width = 600, int height = 220) + { + var chart = new Mat(height, width, MatType.CV_8UC3, new Scalar(30, 30, 30)); + if (histogram == null || histogram.Length == 0) + return chart; + + var totalBins = bins[0] * bins[1] * bins[2]; + var max = 0f; + for (var i = 0; i < histogram.Length; i++) + if (histogram[i] > max) max = histogram[i]; + if (max <= 0) + return chart; + + const int leftPad = 10; + const int bottomPad = 15; + var barWidth = Math.Max(1, (width - 2 * leftPad) / totalBins); + var maxBarHeight = height - bottomPad - 10; + + for (var i = 0; i < totalBins && i < histogram.Length; i++) + { + var binB = i / (bins[1] * bins[2]); + var binG = (i / bins[2]) % bins[1]; + var binR = i % bins[2]; + + var b = (int)((binB + 0.5) * 256 / bins[0]); + var g = (int)((binG + 0.5) * 256 / bins[1]); + var r = (int)((binR + 0.5) * 256 / bins[2]); + + var barHeight = (int)(histogram[i] / max * maxBarHeight); + var x = leftPad + i * barWidth; + var y = height - bottomPad - barHeight; + Cv2.Rectangle(chart, new Rect(x, y, barWidth, barHeight), new Scalar(b, g, r), -1); + } + + return chart; + } + + private static Mat CalcHistogram(Mat bgrImage, Mat mask, int[] bins) + { + var hist = new Mat(); + Cv2.CalcHist( + images: new[] { bgrImage }, + channels: new[] { 0, 1, 2 }, + mask: mask, + hist: hist, + dims: 3, + histSize: bins, + ranges: new[] { new Rangef(0, 256), new Rangef(0, 256), new Rangef(0, 256) }); + Cv2.Normalize(hist, hist); + + var totalBins = bins[0] * bins[1] * bins[2]; + var reshaped = hist.Reshape(1, totalBins).Clone(); + hist.Dispose(); + return reshaped; + } + + private static float[] HistogramToArray(Mat hist) + { + var totalBins = hist.Rows * hist.Cols; + var arr = new float[totalBins]; + for (var i = 0; i < totalBins; i++) + arr[i] = hist.At(i, 0); + return arr; + } + + private static Mat ArrayToHistogram(float[] data, int[] bins) + { + var totalBins = bins[0] * bins[1] * bins[2]; + var mat = new Mat(totalBins, 1, MatType.CV_32F, Scalar.All(0)); + var count = Math.Min(data.Length, totalBins); + for (var i = 0; i < count; i++) + mat.Set(i, 0, data[i]); + return mat; + } +} diff --git a/Plugins/LindtLeerformPlugin/Services/LeerformRecipeStore.cs b/Plugins/LindtLeerformPlugin/Services/LeerformRecipeStore.cs new file mode 100644 index 0000000..805dc4e --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Services/LeerformRecipeStore.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using LindtLeerformPlugin.Models; +using OpenCvSharp; + +namespace LindtLeerformPlugin.Services; + +public class LeerformRecipeStore +{ + private const string RecipeExtension = ".jleerform"; + + public string RecipesDirectory { get; } = Path.Combine("..", "Data", "Recipes"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public IReadOnlyList ListRecipeNames() + { + if (!Directory.Exists(RecipesDirectory)) + return Array.Empty(); + + return Directory.GetFiles(RecipesDirectory, "*" + RecipeExtension) + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name!) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public LeerformRecipe? Load(string recipeName) + { + if (string.IsNullOrWhiteSpace(recipeName)) + return null; + + var path = GetRecipePath(recipeName); + if (!File.Exists(path)) + return null; + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + public void Save(LeerformRecipe recipe) + { + if (string.IsNullOrWhiteSpace(recipe.RecipeName)) + throw new ArgumentException("Recipe must have a name", nameof(recipe)); + + Directory.CreateDirectory(RecipesDirectory); + var path = GetRecipePath(recipe.RecipeName); + var json = JsonSerializer.Serialize(recipe, JsonOptions); + File.WriteAllText(path, json); + } + + public static Mat? DecodeThumbnail(LeerformRecipe recipe) + { + if (string.IsNullOrEmpty(recipe.ThumbnailBase64)) + return null; + + try + { + var bytes = Convert.FromBase64String(recipe.ThumbnailBase64); + var mat = Cv2.ImDecode(bytes, ImreadModes.Color); + return mat.Empty() ? null : mat; + } + catch + { + return null; + } + } + + private string GetRecipePath(string recipeName) => + Path.Combine(RecipesDirectory, recipeName + RecipeExtension); +} diff --git a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs index 29c1e39..20032e5 100644 --- a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs +++ b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs @@ -1,8 +1,13 @@ +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; @@ -11,10 +16,16 @@ 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; + private float[]? _referenceHistogram; [ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage; [ObservableProperty] private string _statusText = "Ready"; @@ -25,10 +36,39 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable [ObservableProperty] private string _calibrationImageDirectory; [ObservableProperty] private double _rmsError; - public CalibrationWindowViewModel(IImageSource imageSource, LeerformSettings settings) + // 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 decimal? _tolerance = 0.30m; + [ObservableProperty] private bool _hasReferenceHistogram; + + // 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; @@ -40,6 +80,24 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable 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() @@ -119,6 +177,217 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable 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(); + _referenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); + + _referenceFrame?.Dispose(); + _referenceFrame = frame.Clone(); + + HasReferenceHistogram = true; + StatusText = $"Reference set ({_referenceHistogram.Length} bins)"; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to compute reference histogram"); + StatusText = $"Reference error: {ex.Message}"; + } + } + + [RelayCommand] + private void TestPattern() + { + using var frame = GetAnalysisFrame(); + if (frame == null) + { + StatusText = "No active frame to test"; + return; + } + if (_referenceHistogram == null) + { + StatusText = "Set reference histogram first"; + 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(); + + 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 async Task ShowCellHistogramAsync(int imageX, int imageY, Avalonia.Controls.Window owner) + { + using var frame = GetAnalysisFrame(); + if (frame == null) return; + + var pattern = BuildCurrentPattern(); + var cellIndex = _patternService.FindCellAtPoint(imageX, imageY, pattern); + if (cellIndex == null) return; + + if (_referenceHistogram == null) + { + StatusText = "Set reference histogram first"; + return; + } + + try + { + var bins = new[] { 5, 5, 5 }; + var cellHist = _patternService.ComputeCellHistogram(frame, pattern, cellIndex.Value, bins); + var distance = _patternService.CompareHistograms(_referenceHistogram, cellHist, bins); + + using var refChart = LeerformPatternRecognitionService.RenderHistogramChart(_referenceHistogram, bins); + using var cellChart = LeerformPatternRecognitionService.RenderHistogramChart(cellHist, bins); + + var refBitmap = ImageConverter.MatToAvaloniaBitmap(refChart); + var cellBitmap = ImageConverter.MatToAvaloniaBitmap(cellChart); + + var tolerance = (double)(Tolerance ?? 0.30m); + var verdict = distance <= tolerance ? "GOOD" : "BAD"; + var dialogVm = new CellHistogramViewModel + { + Title = $"Cell #{cellIndex.Value}", + ReferenceImage = refBitmap, + CellImage = cellBitmap, + DistanceText = $"Distance: {distance:F4} Tolerance: {tolerance:F2} Verdict: {verdict}" + }; + + var window = new CellHistogramWindow { DataContext = dialogVm }; + await window.ShowDialog(owner); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to show cell histogram"); + StatusText = $"Histogram error: {ex.Message}"; + } + } + + [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; + Tolerance = (decimal)recipe.Tolerance; + + _referenceHistogram = recipe.ReferenceHistogram is { Length: > 0 } + ? recipe.ReferenceHistogram + : null; + HasReferenceHistogram = _referenceHistogram != null; + + _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(), + HistogramBins = [5, 5, 5], + ReferenceHistogram = _referenceHistogram ?? [], + Tolerance = (double)(Tolerance ?? 0.30m) + }; + private async Task PreviewLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -130,6 +399,19 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable _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(() => @@ -152,24 +434,40 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable } } + 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 (displayFrame != frame) + if (owned) displayFrame.Dispose(); return bitmap; } - partial void OnApplyCalibrationChanged(bool value) + private void RefreshPreview() { if (_lastFrame == null || _lastFrame.Empty()) return; @@ -186,6 +484,16 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable } } + 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(); + private void LoadCalibrationMats(Models.CalibrationData data) { _calibCameraMatrix?.Dispose(); @@ -195,8 +503,10 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable public void Dispose() { + _singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged; StopPreview(); _lastFrame?.Dispose(); + _referenceFrame?.Dispose(); _calibCameraMatrix?.Dispose(); _calibDistCoeffs?.Dispose(); } diff --git a/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs b/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs new file mode 100644 index 0000000..e839db0 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs @@ -0,0 +1,11 @@ +using Avalonia.Media.Imaging; + +namespace LindtLeerformPlugin.ViewModels; + +public class CellHistogramViewModel +{ + public string Title { get; set; } = "Cell Histogram"; + public Bitmap? ReferenceImage { get; set; } + public Bitmap? CellImage { get; set; } + public string DistanceText { get; set; } = string.Empty; +} diff --git a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml index 24ed8f7..dec82a4 100644 --- a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml +++ b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml @@ -29,12 +29,12 @@ - + - +