leerform recipe subsystem created

This commit is contained in:
EugeneTes
2026-03-31 13:26:26 +02:00
parent 4bd117af47
commit 1c7a4abd5c
20 changed files with 823 additions and 70 deletions

View File

@@ -6,8 +6,9 @@ namespace CandyboxPlugin.Module;
public class CandyboxRecipeCreationTool:IRecipeCreationTool
{
public bool Enabled { get; set; } = true;
public bool CreateRecipe(out string recipeName)
public Task<string?> CreateRecipeAsync()
{
return MaterialInputBox.Prompt("Recipe name","", out recipeName)==DialogResult.OK;
var result = MaterialInputBox.Prompt("Recipe name","", out var recipeName);
return Task.FromResult(result == DialogResult.OK ? recipeName : null);
}
}

View File

@@ -0,0 +1,26 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using LindtLeerformPlugin.Views;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace LindtLeerformPlugin;
public class LeerformRecipeCreationTool : IRecipeCreationTool
{
public bool Enabled { get; set; } = true;
public async Task<string?> CreateRecipeAsync()
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return null;
var parent = desktop.MainWindow;
if (parent == null)
return null;
var dialog = new RecipeNameDialog();
var result = await dialog.ShowDialog<string?>(parent);
return string.IsNullOrWhiteSpace(result) ? null : result;
}
}

View File

@@ -0,0 +1,72 @@
using System.Diagnostics;
using LindtLeerformPlugin.Services;
using OpenCvSharp;
using Serilog;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace LindtLeerformPlugin;
public class LeerformRecognitionControl : BaseRecognitionControl
{
private readonly IImageSource _imageSource;
private Mat? _cameraMatrix;
private Mat? _distCoeffs;
public LeerformRecognitionControl(
LeerformRecognitionControlSettings settings,
IImageSource imageSource,
ILoadingService loadingService) : base(settings, loadingService)
{
_imageSource = imageSource;
}
public override List<RecipeData> GetRecipesData()
{
return [new RecipeData { RecipeName = "Default" }];
}
protected override void Initialize(RecipeData currentRecipe)
{
_cameraMatrix?.Dispose();
_distCoeffs?.Dispose();
_cameraMatrix = null;
_distCoeffs = null;
var calibrationData = CalibrationDataStore.Load();
if (calibrationData != null)
{
(_cameraMatrix, _distCoeffs) = CameraCalibrationService.LoadCalibrationMats(calibrationData);
Log.Information("Calibration data loaded (RMS error: {RmsError:F3})", calibrationData.RmsError);
}
}
protected override void WarmUp()
{
}
protected override (Mat originalImage, Mat analysisImage, TimeSpan processingTime, TimeSpan acquisitionTime, string[] errorNames)?
ProcessImage(CancellationToken token)
{
var sw = Stopwatch.StartNew();
var image = _imageSource.GetImage(token).Result;
if (image == null)
return null;
var acquisitionTime = sw.Elapsed;
if (_cameraMatrix != null && _distCoeffs != null)
{
var undistorted = new Mat();
Cv2.Undistort(image, undistorted, _cameraMatrix, _distCoeffs);
sw.Stop();
return (image, undistorted, sw.Elapsed, acquisitionTime, []);
}
sw.Stop();
return (image, image, TimeSpan.Zero, acquisitionTime, []);
}
}

View File

@@ -0,0 +1,12 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common.Processing;
namespace LindtLeerformPlugin;
public class LeerformRecognitionControlSettings(string cameraName) : BaseRecognitionControlSettings(cameraName)
{
public override void RegisterSettings(InspectronSettings settings)
{
base.RegisterSettings(settings);
}
}

View File

@@ -1,7 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Inspectron.Settings;
using LindtLeerformPlugin.Models;
using VisionBuilder.UI.Common;
namespace LindtLeerformPlugin;
@@ -15,51 +12,14 @@ public class LeerformSettings : ISettings
CameraName = cameraName;
}
public CalibrationData? CalibrationData { get; set; }
public int CheckerboardRows { get; set; } = 6;
public int CheckerboardCols { get; set; } = 9;
public string CalibrationImageDirectory { get; set; } = "CalibrationImages";
public string CalibrationImageDirectory { get; set; } = Path.Combine("..", "Data", "CalibrationImages");
public void RegisterSettings(InspectronSettings settings)
{
settings.RegisterSimple(this, () => CalibrationData!, $"{CameraName}/Leerform", nameof(CalibrationData));
settings.RegisterSimple(this, () => CheckerboardRows, $"{CameraName}/Leerform", nameof(CheckerboardRows));
settings.RegisterSimple(this, () => CheckerboardCols, $"{CameraName}/Leerform", nameof(CheckerboardCols));
settings.RegisterSimple(this, () => CalibrationImageDirectory, $"{CameraName}/Leerform", nameof(CalibrationImageDirectory));
}
}
public class CalibrationDataConverter : ITypeConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
public object ConvertFrom(object value)
{
if (value is string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "null")
return null!;
return JsonSerializer.Deserialize<CalibrationData>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize CalibrationData from JSON.");
}
throw new InvalidOperationException("Value must be a JSON string.");
}
public object ConvertTo(object value, Type destinationType)
{
if (value is CalibrationData data)
{
return JsonSerializer.Serialize(data, JsonOptions);
}
if (value == null)
{
return "null";
}
throw new InvalidOperationException("Value must be a CalibrationData.");
}
}

View File

@@ -1,8 +1,8 @@
using Inspectron.Settings;
using LindtLeerformPlugin.Models;
using Ninject;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Plugins;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace LindtLeerformPlugin;
@@ -10,12 +10,25 @@ public class Plugin : IPlugin
{
public void RegisterGlobalModules(IKernel kernel)
{
TypeConverterRegistry.Register<CalibrationData>(new CalibrationDataConverter());
kernel.Rebind<IRecipeCreationTool>().To<LeerformRecipeCreationTool>().InSingletonScope();
}
public void RegisterCameraModules(IKernel kernel, string cameraName)
{
kernel.Bind<LeerformSettings, ISettings>().ToConstant(new LeerformSettings(cameraName));
// Remove existing recognition settings ISettings binding before rebinding
var existingRecognitionControlSettings = kernel.GetBindings(typeof(BaseRecognitionControlSettings)).First();
var toRemove = kernel.GetBindings(typeof(ISettings))
.First(x => x.ProviderCallback.Target == existingRecognitionControlSettings.ProviderCallback.Target);
kernel.RemoveBinding(toRemove);
kernel.Bind<LeerformRecognitionControlSettings, BaseRecognitionControlSettings, ISettings>()
.ToConstant(new LeerformRecognitionControlSettings(cameraName));
kernel.Rebind<IRecognitionControl>()
.To<LeerformRecognitionControl>()
.InSingletonScope();
kernel.RegisterModule<LeerformModule>();
}
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LindtLeerformPlugin.Models;
namespace LindtLeerformPlugin.Services;
public static class CalibrationDataStore
{
private static readonly string FilePath = Path.Combine("..", "Data", "Config", "calibration.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public static CalibrationData? Load()
{
if (!File.Exists(FilePath))
return null;
var json = File.ReadAllText(FilePath);
return JsonSerializer.Deserialize<CalibrationData>(json, JsonOptions);
}
public static void Save(CalibrationData data)
{
var directory = Path.GetDirectoryName(FilePath);
if (directory != null)
Directory.CreateDirectory(directory);
var json = JsonSerializer.Serialize(data, JsonOptions);
File.WriteAllText(FilePath, json);
}
}

View File

@@ -1,7 +1,6 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LindtLeerformPlugin.Models;
using LindtLeerformPlugin.Services;
using OpenCvSharp;
using VisionBuilder.UI.Common.Processing;
@@ -14,12 +13,15 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
private readonly LeerformSettings _settings;
private CancellationTokenSource? _previewCts;
private Mat? _lastFrame;
private Mat? _calibCameraMatrix;
private Mat? _calibDistCoeffs;
[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;
@@ -29,8 +31,13 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
_settings = settings;
_calibrationImageDirectory = settings.CalibrationImageDirectory;
_isCalibrated = settings.CalibrationData is { CameraMatrix.Length: > 0 };
_rmsError = settings.CalibrationData?.RmsError ?? 0;
var calibrationData = CalibrationDataStore.Load();
_isCalibrated = calibrationData is { CameraMatrix.Length: > 0 };
_rmsError = calibrationData?.RmsError ?? 0;
if (_isCalibrated)
LoadCalibrationMats(calibrationData!);
UpdateCapturedImageCount();
}
@@ -86,9 +93,10 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
var patternSize = new Size(_settings.CheckerboardCols, _settings.CheckerboardRows);
var data = CameraCalibrationService.Calibrate(CalibrationImageDirectory, patternSize);
_settings.CalibrationData = data;
CalibrationDataStore.Save(data);
_settings.CalibrationImageDirectory = CalibrationImageDirectory;
LoadCalibrationMats(data);
IsCalibrated = true;
RmsError = data.RmsError;
StatusText = $"Calibrated (RMS: {data.RmsError:F4})";
@@ -122,7 +130,18 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
_lastFrame?.Dispose();
_lastFrame = frame;
var bitmap = ImageConverter.MatToAvaloniaBitmap(frame);
var displayFrame = frame;
if (ApplyCalibration && _calibCameraMatrix != null && _calibDistCoeffs != null)
{
displayFrame = new Mat();
Cv2.Undistort(frame, displayFrame, _calibCameraMatrix, _calibDistCoeffs);
}
var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame);
if (displayFrame != frame)
displayFrame.Dispose();
await Dispatcher.UIThread.InvokeAsync(() =>
{
var old = PreviewImage;
@@ -143,9 +162,18 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
}
}
private void LoadCalibrationMats(Models.CalibrationData data)
{
_calibCameraMatrix?.Dispose();
_calibDistCoeffs?.Dispose();
(_calibCameraMatrix, _calibDistCoeffs) = CameraCalibrationService.LoadCalibrationMats(data);
}
public void Dispose()
{
StopPreview();
_lastFrame?.Dispose();
_calibCameraMatrix?.Dispose();
_calibDistCoeffs?.Dispose();
}
}

View File

@@ -29,31 +29,30 @@
</Border>
<!-- Settings Panel -->
<Border DockPanel.Dock="Right" Width="220" Padding="12" Background="#2D2D2D">
<Border DockPanel.Dock="Right" Width="220" Padding="12" Background="White">
<ScrollViewer>
<StackPanel Spacing="8">
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="White" />
<TextBlock Text="Image Directory" Foreground="LightGray" />
<TextBox Text="{Binding CalibrationImageDirectory}" />
<Separator Margin="0,8" />
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4"
Background="#00C853" Foreground="White" FontWeight="Bold" />
Background="Red" Foreground="White" FontWeight="Bold" />
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
Background="#FF6D00" Foreground="White" FontWeight="Bold" />
Background="White" Foreground="Red" FontWeight="Bold"
BorderBrush="Red" BorderThickness="1" />
<Separator Margin="0,8" />
<TextBlock Text="Calibrated" Foreground="LimeGreen" IsVisible="{Binding IsCalibrated}" />
<TextBlock Foreground="LightGray" IsVisible="{Binding IsCalibrated}">
<TextBlock Foreground="Gray" IsVisible="{Binding IsCalibrated}">
<TextBlock.Text>
<MultiBinding StringFormat="RMS Error: {0:F4}">
<Binding Path="RmsError" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<CheckBox Content="Apply Calibration" IsChecked="{Binding ApplyCalibration}"
IsEnabled="{Binding IsCalibrated}" Foreground="Black" Margin="0,4" />
</StackPanel>
</ScrollViewer>
</Border>

View File

@@ -0,0 +1,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LindtLeerformPlugin.Views.RecipeNameDialog"
Title="New Recipe"
Width="400" Height="180"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto" Margin="16">
<TextBlock Grid.Row="0"
Text="Recipe name:"
FontSize="14"
Margin="0,0,0,8" />
<TextBox Grid.Row="1"
x:Name="TxtRecipeName"
Margin="0,0,0,16" />
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8">
<Button x:Name="BtnOk"
Content="OK"
Width="80" Height="36"
HorizontalContentAlignment="Center" />
<Button x:Name="BtnCancel"
Content="Cancel"
Width="80" Height="36"
HorizontalContentAlignment="Center" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,23 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace LindtLeerformPlugin.Views;
public partial class RecipeNameDialog : Window
{
public RecipeNameDialog()
{
InitializeComponent();
BtnOk.Click += (_, _) => Close(TxtRecipeName.Text);
BtnCancel.Click += (_, _) => Close(null);
TxtRecipeName.KeyDown += (_, e) =>
{
if (e.Key == Key.Enter)
Close(TxtRecipeName.Text);
else if (e.Key == Key.Escape)
Close(null);
};
}
}