diff --git a/CLAUDE.md b/CLAUDE.md index 8be0db2..543afdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,9 @@ Modules implement `IVisionBuilderModule.InitializeModule()` for deferred initial For detailed plugin architecture documentation and step-by-step implementation guide, see [`docs/PLUGIN_SYSTEM.md`](docs/PLUGIN_SYSTEM.md). +### Recognition Control +`IRecognitionControl` manages the per-camera image processing lifecycle (start/stop/pause, image loop, events). `BaseRecognitionControl` provides the threading, loop, and event infrastructure — subclasses implement `Initialize`, `WarmUp`, `ProcessImage`, and `GetRecipesData`. Each camera gets its own singleton instance via the child kernel. For full implementation guide, settings reference, and existing implementations, see [`docs/RECOGNITION_CONTROL.md`](docs/RECOGNITION_CONTROL.md). + ### DI Container Ninject `StandardKernel` is the root container. Child kernels (`Ninject.Extensions.ChildKernel`) scope per-camera services. All service resolution flows through the kernel — avoid `new` for services. @@ -89,7 +92,7 @@ Ninject `StandardKernel` is the root container. Child kernels (`Ninject.Extensio ## Conventions -- Target platform is **x64** Windows. +- Target platform is **x64** Windows, but new code should be cross-platform — use Avalonia for UI, avoid WinForms-only or Windows-specific APIs. - Culture is forced to `en-US` at startup. - Nullable reference types are enabled across most projects. - Operation attributes: `[Category("name")]` for UI grouping, `[NotForTool]` to exclude properties from serialization, `[IgnoreOperation]` to hide from discovery. diff --git a/Plugins/CandyboxPlugin/Modules/CandyboxRecipeCreationTool.cs b/Plugins/CandyboxPlugin/Modules/CandyboxRecipeCreationTool.cs index 5381c27..14451ec 100644 --- a/Plugins/CandyboxPlugin/Modules/CandyboxRecipeCreationTool.cs +++ b/Plugins/CandyboxPlugin/Modules/CandyboxRecipeCreationTool.cs @@ -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 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); } } \ No newline at end of file diff --git a/Plugins/LindtLeerformPlugin/LeerformRecipeCreationTool.cs b/Plugins/LindtLeerformPlugin/LeerformRecipeCreationTool.cs new file mode 100644 index 0000000..b483c0c --- /dev/null +++ b/Plugins/LindtLeerformPlugin/LeerformRecipeCreationTool.cs @@ -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 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(parent); + + return string.IsNullOrWhiteSpace(result) ? null : result; + } +} diff --git a/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs b/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs new file mode 100644 index 0000000..fd370f5 --- /dev/null +++ b/Plugins/LindtLeerformPlugin/LeerformRecognitionControl.cs @@ -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 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, []); + } +} diff --git a/Plugins/LindtLeerformPlugin/LeerformRecognitionControlSettings.cs b/Plugins/LindtLeerformPlugin/LeerformRecognitionControlSettings.cs new file mode 100644 index 0000000..bf9ac4b --- /dev/null +++ b/Plugins/LindtLeerformPlugin/LeerformRecognitionControlSettings.cs @@ -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); + } +} diff --git a/Plugins/LindtLeerformPlugin/LeerformSettings.cs b/Plugins/LindtLeerformPlugin/LeerformSettings.cs index c073c9c..9fd78d5 100644 --- a/Plugins/LindtLeerformPlugin/LeerformSettings.cs +++ b/Plugins/LindtLeerformPlugin/LeerformSettings.cs @@ -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(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."); - } -} diff --git a/Plugins/LindtLeerformPlugin/Plugin.cs b/Plugins/LindtLeerformPlugin/Plugin.cs index 5b180d3..60e594a 100644 --- a/Plugins/LindtLeerformPlugin/Plugin.cs +++ b/Plugins/LindtLeerformPlugin/Plugin.cs @@ -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(new CalibrationDataConverter()); + kernel.Rebind().To().InSingletonScope(); } public void RegisterCameraModules(IKernel kernel, string cameraName) { kernel.Bind().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() + .ToConstant(new LeerformRecognitionControlSettings(cameraName)); + kernel.Rebind() + .To() + .InSingletonScope(); + kernel.RegisterModule(); } } diff --git a/Plugins/LindtLeerformPlugin/Services/CalibrationDataStore.cs b/Plugins/LindtLeerformPlugin/Services/CalibrationDataStore.cs new file mode 100644 index 0000000..0e2a48d --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Services/CalibrationDataStore.cs @@ -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(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); + } +} diff --git a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs index e763171..8a33e1d 100644 --- a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs +++ b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs @@ -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(); } } diff --git a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml index 2055c1f..24ed8f7 100644 --- a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml +++ b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml @@ -29,31 +29,30 @@ - + - - - - - - + +