cell histogram
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(dotnet sln:*)"
|
"Bash(dotnet sln:*)",
|
||||||
|
"Bash(dotnet build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using LindtLeerformPlugin.Services;
|
||||||
using LindtLeerformPlugin.ViewModels;
|
using LindtLeerformPlugin.ViewModels;
|
||||||
using LindtLeerformPlugin.Views;
|
using LindtLeerformPlugin.Views;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -15,12 +16,21 @@ public class LeerformModule : IVisionBuilderModule
|
|||||||
private readonly SingleCameraVM _singleCameraVm;
|
private readonly SingleCameraVM _singleCameraVm;
|
||||||
private readonly LeerformSettings _settings;
|
private readonly LeerformSettings _settings;
|
||||||
private readonly IImageSource _imageSource;
|
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;
|
_singleCameraVm = singleCameraVm;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_imageSource = imageSource;
|
_imageSource = imageSource;
|
||||||
|
_patternService = patternService;
|
||||||
|
_recipeStore = recipeStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void InitializeModule()
|
public void InitializeModule()
|
||||||
@@ -45,7 +55,7 @@ public class LeerformModule : IVisionBuilderModule
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parent = desktop.MainWindow;
|
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 };
|
var window = new CalibrationWindow { DataContext = vm };
|
||||||
|
|
||||||
if (parent != null)
|
if (parent != null)
|
||||||
|
|||||||
@@ -10,20 +10,40 @@ namespace LindtLeerformPlugin;
|
|||||||
public class LeerformRecognitionControl : BaseRecognitionControl
|
public class LeerformRecognitionControl : BaseRecognitionControl
|
||||||
{
|
{
|
||||||
private readonly IImageSource _imageSource;
|
private readonly IImageSource _imageSource;
|
||||||
|
private readonly LeerformPatternRecognitionService _patternService;
|
||||||
|
private readonly LeerformRecipeStore _recipeStore;
|
||||||
private Mat? _cameraMatrix;
|
private Mat? _cameraMatrix;
|
||||||
private Mat? _distCoeffs;
|
private Mat? _distCoeffs;
|
||||||
|
|
||||||
public LeerformRecognitionControl(
|
public LeerformRecognitionControl(
|
||||||
LeerformRecognitionControlSettings settings,
|
LeerformRecognitionControlSettings settings,
|
||||||
IImageSource imageSource,
|
IImageSource imageSource,
|
||||||
ILoadingService loadingService) : base(settings, loadingService)
|
ILoadingService loadingService,
|
||||||
|
LeerformPatternRecognitionService patternService,
|
||||||
|
LeerformRecipeStore recipeStore) : base(settings, loadingService)
|
||||||
{
|
{
|
||||||
_imageSource = imageSource;
|
_imageSource = imageSource;
|
||||||
|
_patternService = patternService;
|
||||||
|
_recipeStore = recipeStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<RecipeData> GetRecipesData()
|
public override List<RecipeData> GetRecipesData()
|
||||||
{
|
{
|
||||||
return [new RecipeData { RecipeName = "Default" }];
|
var names = _recipeStore.ListRecipeNames();
|
||||||
|
if (names.Count == 0)
|
||||||
|
return [new RecipeData { RecipeName = "Default" }];
|
||||||
|
|
||||||
|
var recipes = new List<RecipeData>(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)
|
protected override void Initialize(RecipeData currentRecipe)
|
||||||
|
|||||||
13
Plugins/LindtLeerformPlugin/Models/CellPattern.cs
Normal file
13
Plugins/LindtLeerformPlugin/Models/CellPattern.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
13
Plugins/LindtLeerformPlugin/Models/CellRegion.cs
Normal file
13
Plugins/LindtLeerformPlugin/Models/CellRegion.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
8
Plugins/LindtLeerformPlugin/Models/CellResult.cs
Normal file
8
Plugins/LindtLeerformPlugin/Models/CellResult.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
7
Plugins/LindtLeerformPlugin/Models/CellShape.cs
Normal file
7
Plugins/LindtLeerformPlugin/Models/CellShape.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LindtLeerformPlugin.Models;
|
||||||
|
|
||||||
|
public enum CellShape
|
||||||
|
{
|
||||||
|
Square,
|
||||||
|
Round
|
||||||
|
}
|
||||||
11
Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs
Normal file
11
Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using LindtLeerformPlugin.Services;
|
||||||
using Ninject;
|
using Ninject;
|
||||||
using VisionBuilder.UI.Common;
|
using VisionBuilder.UI.Common;
|
||||||
using VisionBuilder.UI.Common.Plugins;
|
using VisionBuilder.UI.Common.Plugins;
|
||||||
@@ -25,6 +26,10 @@ public class Plugin : IPlugin
|
|||||||
|
|
||||||
kernel.Bind<LeerformRecognitionControlSettings, BaseRecognitionControlSettings, ISettings>()
|
kernel.Bind<LeerformRecognitionControlSettings, BaseRecognitionControlSettings, ISettings>()
|
||||||
.ToConstant(new LeerformRecognitionControlSettings(cameraName));
|
.ToConstant(new LeerformRecognitionControlSettings(cameraName));
|
||||||
|
|
||||||
|
kernel.Bind<LeerformPatternRecognitionService>().ToSelf().InSingletonScope();
|
||||||
|
kernel.Bind<LeerformRecipeStore>().ToSelf().InSingletonScope();
|
||||||
|
|
||||||
kernel.Rebind<IRecognitionControl>()
|
kernel.Rebind<IRecognitionControl>()
|
||||||
.To<LeerformRecognitionControl>()
|
.To<LeerformRecognitionControl>()
|
||||||
.InSingletonScope();
|
.InSingletonScope();
|
||||||
|
|||||||
@@ -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<CellRegion> ComputeCells(CellPattern pattern)
|
||||||
|
{
|
||||||
|
var cells = new List<CellRegion>();
|
||||||
|
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<CellResult> 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<CellResult>(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<CellResult> 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<float>(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<float>(i, 0, data[i]);
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Plugins/LindtLeerformPlugin/Services/LeerformRecipeStore.cs
Normal file
77
Plugins/LindtLeerformPlugin/Services/LeerformRecipeStore.cs
Normal file
@@ -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<string> ListRecipeNames()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(RecipesDirectory))
|
||||||
|
return Array.Empty<string>();
|
||||||
|
|
||||||
|
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<LeerformRecipe>(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);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LindtLeerformPlugin.Models;
|
||||||
using LindtLeerformPlugin.Services;
|
using LindtLeerformPlugin.Services;
|
||||||
|
using LindtLeerformPlugin.Views;
|
||||||
using OpenCvSharp;
|
using OpenCvSharp;
|
||||||
|
using Serilog;
|
||||||
|
using VisionBuilder.UI.Common;
|
||||||
using VisionBuilder.UI.Common.Processing;
|
using VisionBuilder.UI.Common.Processing;
|
||||||
|
|
||||||
namespace LindtLeerformPlugin.ViewModels;
|
namespace LindtLeerformPlugin.ViewModels;
|
||||||
@@ -11,10 +16,16 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IImageSource _imageSource;
|
private readonly IImageSource _imageSource;
|
||||||
private readonly LeerformSettings _settings;
|
private readonly LeerformSettings _settings;
|
||||||
|
private readonly SingleCameraVM _singleCameraVm;
|
||||||
|
private readonly LeerformPatternRecognitionService _patternService;
|
||||||
|
private readonly LeerformRecipeStore _recipeStore;
|
||||||
|
|
||||||
private CancellationTokenSource? _previewCts;
|
private CancellationTokenSource? _previewCts;
|
||||||
private Mat? _lastFrame;
|
private Mat? _lastFrame;
|
||||||
private Mat? _calibCameraMatrix;
|
private Mat? _calibCameraMatrix;
|
||||||
private Mat? _calibDistCoeffs;
|
private Mat? _calibDistCoeffs;
|
||||||
|
private Mat? _referenceFrame;
|
||||||
|
private float[]? _referenceHistogram;
|
||||||
|
|
||||||
[ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage;
|
[ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage;
|
||||||
[ObservableProperty] private string _statusText = "Ready";
|
[ObservableProperty] private string _statusText = "Ready";
|
||||||
@@ -25,10 +36,39 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
[ObservableProperty] private string _calibrationImageDirectory;
|
[ObservableProperty] private string _calibrationImageDirectory;
|
||||||
[ObservableProperty] private double _rmsError;
|
[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<CellShape>();
|
||||||
|
|
||||||
|
public CalibrationWindowViewModel(
|
||||||
|
IImageSource imageSource,
|
||||||
|
LeerformSettings settings,
|
||||||
|
SingleCameraVM singleCameraVm,
|
||||||
|
LeerformPatternRecognitionService patternService,
|
||||||
|
LeerformRecipeStore recipeStore)
|
||||||
{
|
{
|
||||||
_imageSource = imageSource;
|
_imageSource = imageSource;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_singleCameraVm = singleCameraVm;
|
||||||
|
_patternService = patternService;
|
||||||
|
_recipeStore = recipeStore;
|
||||||
|
|
||||||
_calibrationImageDirectory = settings.CalibrationImageDirectory;
|
_calibrationImageDirectory = settings.CalibrationImageDirectory;
|
||||||
|
|
||||||
@@ -40,6 +80,24 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
LoadCalibrationMats(calibrationData!);
|
LoadCalibrationMats(calibrationData!);
|
||||||
|
|
||||||
UpdateCapturedImageCount();
|
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()
|
private void UpdateCapturedImageCount()
|
||||||
@@ -119,6 +177,217 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
StatusText = "Calibration images cleared";
|
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)
|
private async Task PreviewLoopAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
@@ -130,6 +399,19 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
_lastFrame?.Dispose();
|
_lastFrame?.Dispose();
|
||||||
_lastFrame = frame;
|
_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);
|
var bitmap = RenderFrame(frame);
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
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)
|
private Avalonia.Media.Imaging.Bitmap RenderFrame(Mat frame)
|
||||||
{
|
{
|
||||||
var displayFrame = frame;
|
var displayFrame = frame;
|
||||||
|
var owned = false;
|
||||||
|
|
||||||
if (ApplyCalibration && _calibCameraMatrix != null && _calibDistCoeffs != null)
|
if (ApplyCalibration && _calibCameraMatrix != null && _calibDistCoeffs != null)
|
||||||
{
|
{
|
||||||
displayFrame = new Mat();
|
displayFrame = new Mat();
|
||||||
Cv2.Undistort(frame, displayFrame, _calibCameraMatrix, _calibDistCoeffs);
|
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);
|
var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame);
|
||||||
|
|
||||||
if (displayFrame != frame)
|
if (owned)
|
||||||
displayFrame.Dispose();
|
displayFrame.Dispose();
|
||||||
|
|
||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnApplyCalibrationChanged(bool value)
|
private void RefreshPreview()
|
||||||
{
|
{
|
||||||
if (_lastFrame == null || _lastFrame.Empty()) return;
|
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)
|
private void LoadCalibrationMats(Models.CalibrationData data)
|
||||||
{
|
{
|
||||||
_calibCameraMatrix?.Dispose();
|
_calibCameraMatrix?.Dispose();
|
||||||
@@ -195,8 +503,10 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged;
|
||||||
StopPreview();
|
StopPreview();
|
||||||
_lastFrame?.Dispose();
|
_lastFrame?.Dispose();
|
||||||
|
_referenceFrame?.Dispose();
|
||||||
_calibCameraMatrix?.Dispose();
|
_calibCameraMatrix?.Dispose();
|
||||||
_calibDistCoeffs?.Dispose();
|
_calibDistCoeffs?.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,12 +29,12 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Settings Panel -->
|
<!-- Settings Panel -->
|
||||||
<Border DockPanel.Dock="Right" Width="220" Padding="12" Background="White">
|
<Border DockPanel.Dock="Right" Width="260" Padding="12" Background="White">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
|
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
|
||||||
|
|
||||||
|
|
||||||
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4"
|
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4"
|
||||||
Background="Red" Foreground="White" FontWeight="Bold" />
|
Background="Red" Foreground="White" FontWeight="Bold" />
|
||||||
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
|
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
|
||||||
@@ -53,13 +53,69 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
<CheckBox Content="Apply Calibration" IsChecked="{Binding ApplyCalibration}"
|
<CheckBox Content="Apply Calibration" IsChecked="{Binding ApplyCalibration}"
|
||||||
IsEnabled="{Binding IsCalibrated}" Foreground="Black" Margin="0,4" />
|
IsEnabled="{Binding IsCalibrated}" Foreground="Black" Margin="0,4" />
|
||||||
|
|
||||||
|
<Separator Margin="0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="Pattern" FontWeight="Bold" FontSize="14" Foreground="Black" />
|
||||||
|
|
||||||
|
<TextBlock Text="Rows" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding Rows}" Minimum="1" Maximum="200" Increment="1" FormatString="0" />
|
||||||
|
|
||||||
|
<TextBlock Text="Cols" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding Cols}" Minimum="1" Maximum="200" Increment="1" FormatString="0" />
|
||||||
|
|
||||||
|
<TextBlock Text="Shape" Foreground="Black" />
|
||||||
|
<ComboBox ItemsSource="{Binding AvailableCellShapes}" SelectedItem="{Binding SelectedCellShape}" HorizontalAlignment="Stretch" />
|
||||||
|
|
||||||
|
<TextBlock Text="Padding (px)" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding Padding}" Minimum="0" Maximum="500" Increment="1" FormatString="0" />
|
||||||
|
|
||||||
|
<Separator Margin="0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="ROI (px)" FontWeight="Bold" FontSize="14" Foreground="Black" />
|
||||||
|
|
||||||
|
<TextBlock Text="X" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding RoiX}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
|
||||||
|
<TextBlock Text="Y" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding RoiY}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
|
||||||
|
<TextBlock Text="Width" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding RoiWidth}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
|
||||||
|
<TextBlock Text="Height" Foreground="Black" />
|
||||||
|
<NumericUpDown Value="{Binding RoiHeight}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
|
||||||
|
|
||||||
|
<Separator Margin="0,8" />
|
||||||
|
|
||||||
|
<Button Content="Set Reference" Command="{Binding SetReferenceHistogramCommand}" Margin="0,4"
|
||||||
|
Background="#2E7D32" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
|
||||||
|
<TextBlock Text="✓ Reference set" Foreground="LimeGreen" IsVisible="{Binding HasReferenceHistogram}" />
|
||||||
|
|
||||||
|
<TextBlock Text="Tolerance" Foreground="Black" Margin="0,4,0,0" />
|
||||||
|
<NumericUpDown Value="{Binding Tolerance}" Minimum="0" Maximum="1" Increment="0.05" FormatString="0.00" />
|
||||||
|
|
||||||
|
<Button Content="Test Pattern" Command="{Binding TestPatternCommand}" Margin="0,4"
|
||||||
|
Background="#1565C0" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
|
||||||
|
|
||||||
|
<Separator Margin="0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="Recipe" FontWeight="Bold" FontSize="14" Foreground="Black" />
|
||||||
|
<TextBlock Foreground="Black">
|
||||||
|
<TextBlock.Text>
|
||||||
|
<MultiBinding StringFormat="Current: {0}">
|
||||||
|
<Binding Path="CurrentRecipeName" />
|
||||||
|
</MultiBinding>
|
||||||
|
</TextBlock.Text>
|
||||||
|
</TextBlock>
|
||||||
|
<Button Content="Save Recipe" Command="{Binding SaveRecipeCommand}" Margin="0,4"
|
||||||
|
Background="#6A1B9A" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Live Preview -->
|
<!-- Live Preview -->
|
||||||
<Border Background="#1A1A1A" Margin="4">
|
<Border Background="#1A1A1A" Margin="4">
|
||||||
<Image Source="{Binding PreviewImage}" Stretch="Uniform" />
|
<Image x:Name="PreviewImageControl"
|
||||||
|
Source="{Binding PreviewImage}"
|
||||||
|
Stretch="Uniform" />
|
||||||
</Border>
|
</Border>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
using LindtLeerformPlugin.ViewModels;
|
using LindtLeerformPlugin.ViewModels;
|
||||||
|
|
||||||
namespace LindtLeerformPlugin.Views;
|
namespace LindtLeerformPlugin.Views;
|
||||||
|
|
||||||
public partial class CalibrationWindow : Window
|
public partial class CalibrationWindow : Window
|
||||||
{
|
{
|
||||||
|
private static readonly Cursor HandCursor = new(StandardCursorType.Hand);
|
||||||
|
private static readonly Cursor DefaultCursor = new(StandardCursorType.Arrow);
|
||||||
|
|
||||||
public CalibrationWindow()
|
public CalibrationWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
PreviewImageControl.PointerMoved += OnPreviewPointerMoved;
|
||||||
|
PreviewImageControl.PointerPressed += OnPreviewPointerPressed;
|
||||||
|
PreviewImageControl.PointerExited += OnPreviewPointerExited;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClosing(WindowClosingEventArgs e)
|
protected override void OnClosing(WindowClosingEventArgs e)
|
||||||
@@ -15,4 +24,64 @@ public partial class CalibrationWindow : Window
|
|||||||
(DataContext as CalibrationWindowViewModel)?.Dispose();
|
(DataContext as CalibrationWindowViewModel)?.Dispose();
|
||||||
base.OnClosing(e);
|
base.OnClosing(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private (int x, int y)? ToImageCoordinates(Avalonia.Point pointerPos)
|
||||||
|
{
|
||||||
|
if (PreviewImageControl.Source is not Bitmap bitmap)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ctrlSize = PreviewImageControl.Bounds.Size;
|
||||||
|
var imgSize = bitmap.Size;
|
||||||
|
if (ctrlSize.Width <= 0 || ctrlSize.Height <= 0 || imgSize.Width <= 0 || imgSize.Height <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var scale = System.Math.Min(ctrlSize.Width / imgSize.Width, ctrlSize.Height / imgSize.Height);
|
||||||
|
var displayW = imgSize.Width * scale;
|
||||||
|
var displayH = imgSize.Height * scale;
|
||||||
|
var offsetX = (ctrlSize.Width - displayW) / 2;
|
||||||
|
var offsetY = (ctrlSize.Height - displayH) / 2;
|
||||||
|
|
||||||
|
var imgX = (pointerPos.X - offsetX) / scale;
|
||||||
|
var imgY = (pointerPos.Y - offsetY) / scale;
|
||||||
|
|
||||||
|
if (imgX < 0 || imgY < 0 || imgX >= imgSize.Width || imgY >= imgSize.Height)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return ((int)imgX, (int)imgY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewPointerMoved(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not CalibrationWindowViewModel vm)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coords = ToImageCoordinates(e.GetPosition(PreviewImageControl));
|
||||||
|
if (coords == null)
|
||||||
|
{
|
||||||
|
PreviewImageControl.Cursor = DefaultCursor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cellIndex = vm.HitTestCell(coords.Value.x, coords.Value.y);
|
||||||
|
PreviewImageControl.Cursor = cellIndex.HasValue ? HandCursor : DefaultCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewPointerExited(object? sender, PointerEventArgs e)
|
||||||
|
{
|
||||||
|
PreviewImageControl.Cursor = DefaultCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not CalibrationWindowViewModel vm)
|
||||||
|
return;
|
||||||
|
if (!e.GetCurrentPoint(PreviewImageControl).Properties.IsLeftButtonPressed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coords = ToImageCoordinates(e.GetPosition(PreviewImageControl));
|
||||||
|
if (coords == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await vm.ShowCellHistogramAsync(coords.Value.x, coords.Value.y, this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml
Normal file
26
Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LindtLeerformPlugin.ViewModels"
|
||||||
|
x:Class="LindtLeerformPlugin.Views.CellHistogramWindow"
|
||||||
|
x:DataType="vm:CellHistogramViewModel"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="720" Height="600"
|
||||||
|
Background="#1A1A1A"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Spacing="12" Margin="16">
|
||||||
|
<TextBlock Text="Reference (learned)" FontWeight="Bold" Foreground="White" FontSize="14" />
|
||||||
|
<Border Background="#0A0A0A" Padding="4">
|
||||||
|
<Image Source="{Binding ReferenceImage}" Stretch="Uniform" Height="220" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="Selected cell" FontWeight="Bold" Foreground="White" FontSize="14" Margin="0,8,0,0" />
|
||||||
|
<Border Background="#0A0A0A" Padding="4">
|
||||||
|
<Image Source="{Binding CellImage}" Stretch="Uniform" Height="220" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding DistanceText}" Foreground="LightGray" FontSize="13" Margin="0,8,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LindtLeerformPlugin.Views;
|
||||||
|
|
||||||
|
public partial class CellHistogramWindow : Window
|
||||||
|
{
|
||||||
|
public CellHistogramWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user