plugin system docs

custom buttons for plugins
calibration functions for Leeform
This commit is contained in:
EugeneTes
2026-03-30 16:15:47 +02:00
parent a1838cafeb
commit 4bd117af47
76 changed files with 4521 additions and 59 deletions

View File

@@ -68,6 +68,8 @@ Plugins implement `IPlugin` with two registration points:
Modules implement `IVisionBuilderModule.InitializeModule()` for deferred initialization.
For detailed plugin architecture documentation and step-by-step implementation guide, see [`docs/PLUGIN_SYSTEM.md`](docs/PLUGIN_SYSTEM.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.

View File

@@ -0,0 +1,38 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera;
public class CaptureSettings : ISettings
{
public string CameraName { get; }
public CaptureSettings(string cameraName)
{
CameraName = cameraName;
}
public int Width { get; set; } = 2028;
public int Height { get; set; } = 1520;
public int ShutterSpeed { get; set; } = 10000;
public int Framerate { get; set; } = 2;
public double AwbGainRed { get; set; } = 3.86;
public double AwbGainBlue { get; set; } = 1.46;
public string BuildArguments()
{
return $"--codec mjpeg -t0 --width {Width} --height {Height} " +
$"--shutter {ShutterSpeed} --framerate {Framerate} " +
$"--awbgains {AwbGainRed:F2},{AwbGainBlue:F2} --nopreview -o -";
}
public void RegisterSettings(InspectronSettings settings)
{
settings.RegisterSimple(this, () => Width, CameraName + "/Sources/Libcamera", nameof(Width));
settings.RegisterSimple(this, () => Height, CameraName + "/Sources/Libcamera", nameof(Height));
settings.RegisterSimple(this, () => ShutterSpeed, CameraName + "/Sources/Libcamera", nameof(ShutterSpeed));
settings.RegisterSimple(this, () => Framerate, CameraName + "/Sources/Libcamera", nameof(Framerate));
settings.RegisterSimple(this, () => AwbGainRed, CameraName + "/Sources/Libcamera", nameof(AwbGainRed));
settings.RegisterSimple(this, () => AwbGainBlue, CameraName + "/Sources/Libcamera", nameof(AwbGainBlue));
}
}

View File

@@ -8,8 +8,12 @@ namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera
{
public class LibcameraImageSource : IImageSource, IVisionBuilderModule
{
private CaptureSettings _settings;
public LibcameraImageSource(CaptureSettings settings)
{
_settings = settings;
}
public async Task<Mat> GetImage(CancellationToken token)
{
@@ -25,15 +29,6 @@ namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera
byte[] JpegFooter = new byte[] { 0xff, 0xd9 };
int ChunkSize = 1024;
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "libcamera-vid",
Arguments = "--codec mjpeg -t0 --width 2028 --height 1520 --shutter 10000 --framerate 16 --awbgains 3.86,1.46 --nopreview -o -",
RedirectStandardOutput = true,
UseShellExecute = false
};
Channel<Mat> _imageChannel = Channel.CreateBounded<Mat>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
public void Start()
@@ -46,6 +41,14 @@ namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera
private void StartLoop()
{
var psi = new ProcessStartInfo
{
FileName = "rpicam-vid",
Arguments = _settings.BuildArguments(),
RedirectStandardOutput = true,
UseShellExecute = false
};
using (Process process = Process.Start(psi))
using (BinaryReader br = new BinaryReader(process.StandardOutput.BaseStream))
{

View File

@@ -0,0 +1,61 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using LindtLeerformPlugin.ViewModels;
using LindtLeerformPlugin.Views;
using Serilog;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace LindtLeerformPlugin;
public class LeerformModule : IVisionBuilderModule
{
private readonly SingleCameraVM _singleCameraVm;
private readonly LeerformSettings _settings;
private readonly IImageSource _imageSource;
public LeerformModule(SingleCameraVM singleCameraVm, LeerformSettings settings, IImageSource imageSource)
{
_singleCameraVm = singleCameraVm;
_settings = settings;
_imageSource = imageSource;
}
public void InitializeModule()
{
var calibrateButton = new ButtonDefinition("leerform_calibrate", "Calibrate", OnCalibrateClicked);
_singleCameraVm.AddButton(calibrateButton);
}
private void OnCalibrateClicked()
{
_ = ShowCalibrationWindowAsync();
}
private async Task ShowCalibrationWindowAsync()
{
try
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
Log.Warning("Avalonia desktop lifetime not available, cannot show calibration window.");
return;
}
var parent = desktop.MainWindow;
var vm = new CalibrationWindowViewModel(_imageSource, _settings);
var window = new CalibrationWindow { DataContext = vm };
if (parent != null)
await window.ShowDialog(parent);
else
window.Show();
}
catch (Exception ex)
{
Log.Error(ex, "Error showing calibration window");
}
}
}

View File

@@ -0,0 +1,65 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Inspectron.Settings;
using LindtLeerformPlugin.Models;
using VisionBuilder.UI.Common;
namespace LindtLeerformPlugin;
public class LeerformSettings : ISettings
{
public string CameraName { get; }
public LeerformSettings(string cameraName)
{
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 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

@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutDir>..\..\VisionBuilder.UI.Avalonia.Uno\bin\Debug\Data\Plugins\LindtLeerformPlugin</OutDir>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
<ProjectReference Include="..\..\framework\Inspectron.Settings\Inspectron.Settings.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Avalonia.Desktop" Version="11.2.3">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace LindtLeerformPlugin.Models;
public class CalibrationData
{
public double[] CameraMatrix { get; set; } = [];
public double[] DistCoeffs { get; set; } = [];
public double RmsError { get; set; }
public int ImageWidth { get; set; }
public int ImageHeight { get; set; }
}

View File

@@ -0,0 +1,21 @@
using Inspectron.Settings;
using LindtLeerformPlugin.Models;
using Ninject;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Plugins;
namespace LindtLeerformPlugin;
public class Plugin : IPlugin
{
public void RegisterGlobalModules(IKernel kernel)
{
TypeConverterRegistry.Register<CalibrationData>(new CalibrationDataConverter());
}
public void RegisterCameraModules(IKernel kernel, string cameraName)
{
kernel.Bind<LeerformSettings, ISettings>().ToConstant(new LeerformSettings(cameraName));
kernel.RegisterModule<LeerformModule>();
}
}

View File

@@ -0,0 +1,101 @@
using LindtLeerformPlugin.Models;
using OpenCvSharp;
namespace LindtLeerformPlugin.Services;
public static class CameraCalibrationService
{
public static CalibrationData Calibrate(string imageDirectory, Size patternSize)
{
var imageFiles = Directory.GetFiles(imageDirectory, "*.png");
if (imageFiles.Length == 0)
throw new InvalidOperationException($"No PNG images found in {imageDirectory}");
var objectPointsList = new List<Mat>();
var imagePointsList = new List<Mat>();
Size imageSize = default;
int cornerCount = patternSize.Width * patternSize.Height;
var objPts = new Point3f[cornerCount];
for (int row = 0; row < patternSize.Height; row++)
for (int col = 0; col < patternSize.Width; col++)
objPts[row * patternSize.Width + col] = new Point3f(col, row, 0);
int found = 0;
foreach (var file in imageFiles)
{
using var image = Cv2.ImRead(file);
using var gray = new Mat();
Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY);
imageSize = new Size(image.Width, image.Height);
if (Cv2.FindChessboardCorners(gray, patternSize, out var corners,
ChessboardFlags.AdaptiveThresh | ChessboardFlags.FastCheck))
{
Cv2.CornerSubPix(gray, corners,
new Size(11, 11), new Size(-1, -1),
new TermCriteria(CriteriaTypes.Eps | CriteriaTypes.MaxIter, 30, 0.001));
var objMat = new Mat(cornerCount, 1, MatType.CV_32FC3);
for (int i = 0; i < cornerCount; i++)
objMat.Set(i, 0, objPts[i]);
objectPointsList.Add(objMat);
var imgMat = new Mat(corners.Length, 1, MatType.CV_32FC2);
for (int i = 0; i < corners.Length; i++)
imgMat.Set(i, 0, corners[i]);
imagePointsList.Add(imgMat);
found++;
}
}
if (found == 0)
throw new InvalidOperationException("Checkerboard corners not found in any image");
var cameraMatrix = new Mat();
var distCoeffs = new Mat();
double rms = Cv2.CalibrateCamera(
objectPointsList, imagePointsList, imageSize,
cameraMatrix, distCoeffs,
out _, out _);
var data = new CalibrationData
{
CameraMatrix = MatToArray(cameraMatrix),
DistCoeffs = MatToArray(distCoeffs),
RmsError = rms,
ImageWidth = imageSize.Width,
ImageHeight = imageSize.Height
};
cameraMatrix.Dispose();
distCoeffs.Dispose();
foreach (var m in objectPointsList) m.Dispose();
foreach (var m in imagePointsList) m.Dispose();
return data;
}
public static (Mat cameraMatrix, Mat distCoeffs) LoadCalibrationMats(CalibrationData data)
{
var cameraMatrix = new Mat(3, 3, MatType.CV_64F);
for (int i = 0; i < 9; i++)
cameraMatrix.Set(i / 3, i % 3, data.CameraMatrix[i]);
var distCoeffs = new Mat(1, data.DistCoeffs.Length, MatType.CV_64F);
for (int i = 0; i < data.DistCoeffs.Length; i++)
distCoeffs.Set(0, i, data.DistCoeffs[i]);
return (cameraMatrix, distCoeffs);
}
private static double[] MatToArray(Mat mat)
{
var total = mat.Rows * mat.Cols;
var arr = new double[total];
for (int i = 0; i < total; i++)
arr[i] = mat.At<double>(i / mat.Cols, i % mat.Cols);
return arr;
}
}

View File

@@ -0,0 +1,13 @@
using OpenCvSharp;
namespace LindtLeerformPlugin.Services;
public static class ImageConverter
{
public static Avalonia.Media.Imaging.Bitmap MatToAvaloniaBitmap(Mat mat)
{
var encoded = mat.ImEncode(".bmp");
using var ms = new MemoryStream(encoded);
return new Avalonia.Media.Imaging.Bitmap(ms);
}
}

View File

@@ -0,0 +1,151 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LindtLeerformPlugin.Models;
using LindtLeerformPlugin.Services;
using OpenCvSharp;
using VisionBuilder.UI.Common.Processing;
namespace LindtLeerformPlugin.ViewModels;
public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
{
private readonly IImageSource _imageSource;
private readonly LeerformSettings _settings;
private CancellationTokenSource? _previewCts;
private Mat? _lastFrame;
[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 string _calibrationImageDirectory;
[ObservableProperty] private double _rmsError;
public CalibrationWindowViewModel(IImageSource imageSource, LeerformSettings settings)
{
_imageSource = imageSource;
_settings = settings;
_calibrationImageDirectory = settings.CalibrationImageDirectory;
_isCalibrated = settings.CalibrationData is { CameraMatrix.Length: > 0 };
_rmsError = settings.CalibrationData?.RmsError ?? 0;
UpdateCapturedImageCount();
}
private void UpdateCapturedImageCount()
{
if (Directory.Exists(CalibrationImageDirectory))
CapturedImageCount = Directory.GetFiles(CalibrationImageDirectory, "*.png").Length;
else
CapturedImageCount = 0;
}
[RelayCommand]
private void StartPreview()
{
if (IsPreviewRunning) return;
IsPreviewRunning = true;
_previewCts = new CancellationTokenSource();
_ = PreviewLoopAsync(_previewCts.Token);
StatusText = "Preview running";
}
[RelayCommand]
private void StopPreview()
{
if (!IsPreviewRunning) return;
_previewCts?.Cancel();
IsPreviewRunning = false;
StatusText = "Preview stopped";
}
[RelayCommand]
private void CaptureImage()
{
if (_lastFrame == null || _lastFrame.Empty()) return;
Directory.CreateDirectory(CalibrationImageDirectory);
var filename = $"calib_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png";
var path = Path.Combine(CalibrationImageDirectory, filename);
Cv2.ImWrite(path, _lastFrame);
UpdateCapturedImageCount();
StatusText = $"Captured: {filename}";
}
[RelayCommand]
private void RunCalibration()
{
try
{
StatusText = "Calibrating...";
var patternSize = new Size(_settings.CheckerboardCols, _settings.CheckerboardRows);
var data = CameraCalibrationService.Calibrate(CalibrationImageDirectory, patternSize);
_settings.CalibrationData = data;
_settings.CalibrationImageDirectory = CalibrationImageDirectory;
IsCalibrated = true;
RmsError = data.RmsError;
StatusText = $"Calibrated (RMS: {data.RmsError:F4})";
}
catch (Exception ex)
{
StatusText = $"Calibration error: {ex.Message}";
}
}
[RelayCommand]
private void ClearCalibrationImages()
{
if (!Directory.Exists(CalibrationImageDirectory)) return;
foreach (var file in Directory.GetFiles(CalibrationImageDirectory, "*.png"))
File.Delete(file);
UpdateCapturedImageCount();
StatusText = "Calibration images cleared";
}
private async Task PreviewLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var frame = await _imageSource.GetImage(ct);
_lastFrame?.Dispose();
_lastFrame = frame;
var bitmap = ImageConverter.MatToAvaloniaBitmap(frame);
await Dispatcher.UIThread.InvokeAsync(() =>
{
var old = PreviewImage;
PreviewImage = bitmap;
old?.Dispose();
});
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
StatusText = $"Preview error: {ex.Message}");
break;
}
}
}
public void Dispose()
{
StopPreview();
_lastFrame?.Dispose();
}
}

View File

@@ -0,0 +1,67 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LindtLeerformPlugin.ViewModels"
x:Class="LindtLeerformPlugin.Views.CalibrationWindow"
x:DataType="vm:CalibrationWindowViewModel"
Title="Leerform Calibration"
Width="1024" Height="800">
<DockPanel>
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8">
<Button Content="Start Preview" Command="{Binding StartPreviewCommand}" IsEnabled="{Binding !IsPreviewRunning}" />
<Button Content="Stop Preview" Command="{Binding StopPreviewCommand}" IsEnabled="{Binding IsPreviewRunning}" />
<Button Content="Capture Image" Command="{Binding CaptureImageCommand}" />
</StackPanel>
<!-- Status Bar -->
<Border DockPanel.Dock="Bottom" Background="#1E1E1E" Padding="8,4">
<StackPanel Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding StatusText}" Foreground="LightGray" />
<TextBlock Foreground="LightGray">
<TextBlock.Text>
<MultiBinding StringFormat="Captured: {0}">
<Binding Path="CapturedImageCount" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
<!-- Settings Panel -->
<Border DockPanel.Dock="Right" Width="220" Padding="12" Background="#2D2D2D">
<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" />
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4"
Background="#00C853" Foreground="White" FontWeight="Bold" />
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
Background="#FF6D00" Foreground="White" FontWeight="Bold" />
<Separator Margin="0,8" />
<TextBlock Text="Calibrated" Foreground="LimeGreen" IsVisible="{Binding IsCalibrated}" />
<TextBlock Foreground="LightGray" IsVisible="{Binding IsCalibrated}">
<TextBlock.Text>
<MultiBinding StringFormat="RMS Error: {0:F4}">
<Binding Path="RmsError" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Live Preview -->
<Border Background="#1A1A1A" Margin="4">
<Image Source="{Binding PreviewImage}" Stretch="Uniform" />
</Border>
</DockPanel>
</Window>

View File

@@ -0,0 +1,18 @@
using Avalonia.Controls;
using LindtLeerformPlugin.ViewModels;
namespace LindtLeerformPlugin.Views;
public partial class CalibrationWindow : Window
{
public CalibrationWindow()
{
InitializeComponent();
}
protected override void OnClosing(WindowClosingEventArgs e)
{
(DataContext as CalibrationWindowViewModel)?.Dispose();
base.OnClosing(e);
}
}

View File

@@ -0,0 +1,7 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.Avalonia.Uno.App">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,89 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Inspectron.Settings;
using Ninject;
using Ninject.Extensions.ChildKernel;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Plugins;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.IOCommander;
using VisionBuilder.UI.Recipes.HawkeyeRecipe;
using VisionBuilder.UI.Ringbuffer;
using VisionBuilder.UI.Statistics;
using VisionBuilder.UI.Avalonia.Uno.Views;
using Lindt.Colorballs.Duo.Utils;
namespace VisionBuilder.UI.Avalonia.Uno;
public class App : Application
{
private const string CAMERA1 = "Camera 1";
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var args = desktop.Args ?? [];
var commandLineParser = new CommandLineParser(args);
var settings = new InspectronSettings("../Data/Config");
var mainKernel = Common.VisionBuilder.CreateMainKernel(settings);
var profile = commandLineParser.GetStringArgument("profile", 'p');
mainKernel.UsePlugins(profile);
mainKernel
.UseIOCommander([CAMERA1])
.UseAvaloniaServices()
.RegisterGlobalPlugins();
mainKernel.Get<ILoadingService>().StartLoading("Camera initialization");
// CAMERA 1
ChildKernel kernelCamera1 = new ChildKernel(mainKernel);
kernelCamera1
.RegisterCamera(CAMERA1)
.UseStatistics(CAMERA1)
.UseRingbuffer(CAMERA1)
.UseHawkeyeRecipes(CAMERA1)
.UseCameraIOCommander(CAMERA1)
.RegisterCameraPlugins(CAMERA1);
// LOAD SETTINGS
mainKernel.RegisterSettings();
kernelCamera1.RegisterSettings();
settings.LoadSettings();
// Create camera and bind it (must be after settings load)
kernelCamera1.UseCamera();
// Initialize modules
mainKernel.InitializeModules();
kernelCamera1.InitializeModules();
mainKernel.Get<ILoadingService>().StopLoading("Camera initialization");
var mainWindowVm = mainKernel.Get<MainWindowVM>();
mainWindowVm.SingleCameraVms = [
kernelCamera1.Get<SingleCameraVM>()
];
var avaloniaUiSettings = mainKernel.Get<AvaloniaUISettings>();
settings.LoadSettings();
desktop.MainWindow = new MainWindow(mainWindowVm, avaloniaUiSettings);
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,14 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace VisionBuilder.UI.Avalonia.Uno;
public class AvaloniaUISettings : ISettings
{
public bool FullScreen { get; set; } = false;
public void RegisterSettings(InspectronSettings settings)
{
settings.RegisterSimple(this, () => this.FullScreen, "System", nameof(FullScreen));
}
}

View File

@@ -0,0 +1,17 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace VisionBuilder.UI.Avalonia.Uno.Converters;
public class BoolToVisibilityConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value is true;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,23 @@
using System.Globalization;
using Avalonia.Data.Converters;
using OpenCvSharp;
using VisionBuilder.UI.Avalonia.Uno.Services;
namespace VisionBuilder.UI.Avalonia.Uno.Converters;
public class MatToBitmapConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Mat mat && !mat.Empty())
{
return ImageConverter.MatToAvaloniaBitmap(mat);
}
return null;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,22 @@
using Ninject;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.Common.Services;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
using VisionBuilder.UI.Avalonia.Uno.Services;
namespace VisionBuilder.UI.Avalonia.Uno;
public static class ModuleExtensions
{
public static IKernel UseAvaloniaServices(this IKernel self)
{
self.Bind<ISettingsService>().To<AvaloniaSettingsService>().InSingletonScope();
self.Bind<ILoadingService>().To<AvaloniaLoadingService>().InSingletonScope();
self.Bind<IRecipeSelectionDialogService>().To<AvaloniaRecipeSelectionDialogService>().InSingletonScope();
self.Bind<IImagePreviewService>().To<AvaloniaImagePreviewService>().InSingletonScope();
self.Bind<IPasswordInputService>().To<AvaloniaPasswordInputService>().InSingletonScope();
self.Bind<AvaloniaUISettings, ISettings>().To<AvaloniaUISettings>().InSingletonScope();
return self;
}
}

View File

@@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.LinuxFramebuffer;
namespace VisionBuilder.UI.Avalonia.Uno;
public class Program
{
[STAThread]
public static void Main(string[] args)
{
if (args.Contains("--drm"))
BuildAvaloniaApp().StartLinuxDrm(args, card: null, scaling: 1.0);
else
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}

View File

@@ -0,0 +1,26 @@
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
using VisionBuilder.UI.Avalonia.Uno.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public class AvaloniaImagePreviewService : IImagePreviewService
{
public async Task ShowImagePreviewAsync(ErrorPreviewVM errorPreviewVm)
{
var dialog = new ImagePreviewDialog(errorPreviewVm);
var parent = GetMainWindow();
if (parent != null)
await dialog.ShowDialog(parent);
}
private static Window? GetMainWindow()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return desktop.MainWindow;
return null;
}
}

View File

@@ -0,0 +1,17 @@
using Serilog;
using VisionBuilder.UI.Common.Processing;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public class AvaloniaLoadingService : ILoadingService
{
public void StartLoading(string title)
{
Log.Information("Loading: {Title}", title);
}
public void StopLoading(string title)
{
Log.Information("Loaded: {Title}", title);
}
}

View File

@@ -0,0 +1,26 @@
using VisionBuilder.UI.Common.Services;
using VisionBuilder.UI.Avalonia.Uno.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public class AvaloniaPasswordInputService : IPasswordInputService
{
public async Task<string?> GetPasswordAsync()
{
var dialog = new PasswordDialog();
var parent = GetMainWindow();
if (parent != null)
return await dialog.ShowDialog<string?>(parent);
return null;
}
private static Window? GetMainWindow()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return desktop.MainWindow;
return null;
}
}

View File

@@ -0,0 +1,38 @@
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
using VisionBuilder.UI.Avalonia.Uno.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public class AvaloniaRecipeSelectionDialogService : IRecipeSelectionDialogService
{
private readonly IRecipeCreationTool _recipeCreationTool;
public AvaloniaRecipeSelectionDialogService(IRecipeCreationTool recipeCreationTool)
{
_recipeCreationTool = recipeCreationTool;
}
public async Task<bool> SelectRecipeAsync(RecipeSelectionVM recipeSelectionVm)
{
var tcs = new TaskCompletionSource<bool>();
var dialog = new RecipeSelectionDialog(recipeSelectionVm, _recipeCreationTool, tcs);
var parent = GetMainWindow();
if (parent != null)
{
await dialog.ShowDialog(parent);
return tcs.Task.IsCompleted && tcs.Task.Result;
}
return false;
}
private static Window? GetMainWindow()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return desktop.MainWindow;
return null;
}
}

View File

@@ -0,0 +1,11 @@
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public class AvaloniaSettingsService : ISettingsService
{
public Task ShowSettingsDialogAsync()
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,13 @@
using OpenCvSharp;
namespace VisionBuilder.UI.Avalonia.Uno.Services;
public static class ImageConverter
{
public static global::Avalonia.Media.Imaging.Bitmap MatToAvaloniaBitmap(Mat mat)
{
var encoded = mat.ImEncode(".bmp");
using var ms = new MemoryStream(encoded);
return new global::Avalonia.Media.Imaging.Bitmap(ms);
}
}

View File

@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.Avalonia.Uno.Views.ImagePreviewDialog"
Title="Image Preview"
WindowState="Maximized"
WindowStartupLocation="CenterOwner"
Background="White"
CanResize="True">
<Grid RowDefinitions="Auto,Auto,*" Margin="16">
<!-- Row 0: Error name label -->
<TextBlock x:Name="LblImageName"
Grid.Row="0"
HorizontalAlignment="Center"
FontSize="16"
Margin="0,8,0,0" />
<!-- Row 1: Toolbar buttons -->
<Grid Grid.Row="1" Margin="0,8,0,8">
<!-- Left-aligned buttons -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Left"
Spacing="8">
<Button x:Name="BtnPrev"
Content="PREVIOUS IMAGE"
Width="168" Height="64"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnSwitchView"
Content="SHOW ORIGINAL"
Width="168" Height="64"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
</StackPanel>
<!-- Right-aligned buttons -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8">
<Button x:Name="BtnLearn"
Content="LEARN THIS"
Width="168" Height="64"
IsVisible="False"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnNext"
Content="NEXT IMAGE"
Width="168" Height="64"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnClose"
Content="CLOSE"
Width="168" Height="64"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="#D32F2F"
Foreground="White" />
</StackPanel>
</Grid>
<!-- Row 2: Image -->
<Image x:Name="PreviewImage"
Grid.Row="2"
Stretch="Uniform" />
</Grid>
</Window>

View File

@@ -0,0 +1,74 @@
using System.ComponentModel;
using Avalonia.Controls;
using VisionBuilder.UI.Avalonia.Uno.Services;
using VisionBuilder.UI.Common.ViewModel;
namespace VisionBuilder.UI.Avalonia.Uno.Views;
public partial class ImagePreviewDialog : Window
{
private readonly ErrorPreviewVM _previewVm;
public ImagePreviewDialog(ErrorPreviewVM errorPreviewVm)
{
_previewVm = errorPreviewVm;
InitializeComponent();
_previewVm.PropertyChanged += PreviewVm_PropertyChanged;
// Initial state
UpdateImage();
BtnLearn.IsVisible = _previewVm.LearnButtonVisible;
BtnPrev.IsEnabled = _previewVm.IsPreviousImageEnabled;
BtnNext.IsEnabled = _previewVm.IsNextImageEnabled;
LblImageName.Text = _previewVm.ImageName;
// Wire button events (direct method calls like WinForms)
BtnPrev.Click += (_, _) => _previewVm.PreviousImage();
BtnNext.Click += (_, _) => _previewVm.NextImage();
BtnSwitchView.Click += (_, _) => _previewVm.SwitchView();
BtnLearn.Click += (_, _) => _previewVm.Learn();
BtnClose.Click += (_, _) => Close();
}
private void PreviewVm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
switch (e.PropertyName)
{
case nameof(ErrorPreviewVM.ImageAnalysis):
UpdateImage();
break;
case nameof(ErrorPreviewVM.LearnButtonVisible):
BtnLearn.IsVisible = _previewVm.LearnButtonVisible;
break;
case nameof(ErrorPreviewVM.IsPreviousImageEnabled):
BtnPrev.IsEnabled = _previewVm.IsPreviousImageEnabled;
break;
case nameof(ErrorPreviewVM.IsNextImageEnabled):
BtnNext.IsEnabled = _previewVm.IsNextImageEnabled;
break;
case nameof(ErrorPreviewVM.ImageName):
LblImageName.Text = _previewVm.ImageName;
break;
case nameof(ErrorPreviewVM.OriginalView):
BtnSwitchView.Content = _previewVm.OriginalView ? "SHOW ANALYSIS" : "SHOW ORIGINAL";
break;
}
});
}
private void UpdateImage()
{
if (_previewVm.ImageAnalysis != null && !_previewVm.ImageAnalysis.Empty())
{
PreviewImage.Source = ImageConverter.MatToAvaloniaBitmap(_previewVm.ImageAnalysis);
}
else
{
PreviewImage.Source = null;
}
}
}

View File

@@ -0,0 +1,54 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:VisionBuilder.UI.Avalonia.Uno.Views"
x:Class="VisionBuilder.UI.Avalonia.Uno.Views.MainWindow"
Title="VisionBuilder UNO"
Width="1920" Height="1080"
WindowStartupLocation="CenterScreen"
Background="#FFFFFF">
<Grid RowDefinitions="*,1,Auto">
<!-- Main camera view -->
<views:SingleCameraView x:Name="SingleCameraView" Grid.Row="0" />
<!-- Divider -->
<Border Grid.Row="1" Background="#D32F2F" />
<!-- Bottom bar -->
<Grid Grid.Row="2" Height="70" Background="White">
<StackPanel x:Name="BottomBarPanel" Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="BtnMinimize"
Content="MINIMIZE"
Width="176" Height="48"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnExit"
Content="EXIT"
Width="176" Height="48"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
</StackPanel>
<TextBlock x:Name="LblVersion"
Text="v0.0.0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,8,4"
FontSize="12"
Foreground="#888888" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,197 @@
using System.Collections.Specialized;
using System.ComponentModel;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Avalonia.Uno.Views;
public partial class MainWindow : Window
{
private readonly MainWindowVM _mainWindowVm;
private readonly AvaloniaUISettings _avaloniaUiSettings;
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
public MainWindow(MainWindowVM mainWindowVm, AvaloniaUISettings avaloniaUiSettings)
{
_mainWindowVm = mainWindowVm;
_avaloniaUiSettings = avaloniaUiSettings;
InitializeComponent();
DataContext = _mainWindowVm;
SingleCameraView.SetViewModel(_mainWindowVm.SingleCameraVms[0]);
BtnMinimize.Click += BtnMinimize_Click;
BtnExit.Click += BtnExit_Click;
var version = Assembly.GetExecutingAssembly().GetName().Version;
LblVersion.Text = version != null ? $"v{version.Major}.{version.Minor}.{version.Build}" : "v0.0.0";
_mainWindowVm.PropertyChanged += MainWindowVm_PropertyChanged;
_mainWindowVm.DynamicButtons.CollectionChanged += DynamicButtons_CollectionChanged;
foreach (var btn in _mainWindowVm.DynamicButtons)
AddDynamicButton(btn);
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
_mainWindowVm.OnProgramStarted();
if (_avaloniaUiSettings.FullScreen)
{
WindowState = WindowState.Maximized;
}
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
_mainWindowVm.OnProgramClosed();
Environment.Exit(0);
}
private void MainWindowVm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(_mainWindowVm.TestMode))
{
// Toggle accent color for test mode visual indication
// Green background when test mode is active
if (_mainWindowVm.TestMode)
{
Background = new global::Avalonia.Media.SolidColorBrush(
global::Avalonia.Media.Color.FromRgb(0x2E, 0x7D, 0x32)); // Green800
}
else
{
Background = new global::Avalonia.Media.SolidColorBrush(
global::Avalonia.Media.Colors.White);
}
}
}
private void BtnMinimize_Click(object? sender, RoutedEventArgs e)
{
WindowState = WindowState.Minimized;
}
private void BtnExit_Click(object? sender, RoutedEventArgs e)
{
_mainWindowVm.OnProgramClosed();
Environment.Exit(0);
}
private void DynamicButtons_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (ButtonDefinition btn in e.NewItems!)
AddDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Remove:
foreach (ButtonDefinition btn in e.OldItems!)
RemoveDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var ctrl in _dynamicButtonControls.Values)
BottomBarPanel.Children.Remove(ctrl);
_dynamicButtonControls.Clear();
break;
}
});
}
private void AddDynamicButton(ButtonDefinition buttonDef)
{
Control control;
if (buttonDef.Type == EButtonType.Toggle)
{
var toggleBtn = new ToggleButton
{
Content = buttonDef.Title.ToUpperInvariant(),
Width = 176,
Height = 48,
FontSize = 14,
FontWeight = FontWeight.Bold,
HorizontalContentAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
VerticalContentAlignment = global::Avalonia.Layout.VerticalAlignment.Center,
IsChecked = buttonDef.IsToggled,
IsVisible = buttonDef.IsVisible,
BorderBrush = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F)),
BorderThickness = new Thickness(2),
Background = Brushes.White,
Foreground = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F))
};
toggleBtn.Click += (_, _) => buttonDef.Execute();
buttonDef.PropertyChanged += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
toggleBtn.IsVisible = buttonDef.IsVisible;
else if (args.PropertyName == nameof(ButtonDefinition.IsToggled))
toggleBtn.IsChecked = buttonDef.IsToggled;
});
};
control = toggleBtn;
}
else
{
var btn = new Button
{
Content = buttonDef.Title.ToUpperInvariant(),
Width = 176,
Height = 48,
FontSize = 14,
FontWeight = FontWeight.Bold,
HorizontalContentAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
VerticalContentAlignment = global::Avalonia.Layout.VerticalAlignment.Center,
IsVisible = buttonDef.IsVisible,
BorderBrush = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F)),
BorderThickness = new Thickness(2),
Background = Brushes.White,
Foreground = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F))
};
btn.Click += (_, _) => buttonDef.Execute();
buttonDef.PropertyChanged += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
btn.IsVisible = buttonDef.IsVisible;
});
};
control = btn;
}
// Insert before BtnMinimize
var minimizeIndex = BottomBarPanel.Children.IndexOf(BtnMinimize);
if (minimizeIndex >= 0)
BottomBarPanel.Children.Insert(minimizeIndex, control);
else
BottomBarPanel.Children.Add(control);
_dynamicButtonControls[buttonDef.Key] = control;
}
private void RemoveDynamicButton(ButtonDefinition buttonDef)
{
if (_dynamicButtonControls.TryGetValue(buttonDef.Key, out var ctrl))
{
BottomBarPanel.Children.Remove(ctrl);
_dynamicButtonControls.Remove(buttonDef.Key);
}
}
}

View File

@@ -0,0 +1,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.Avalonia.Uno.Views.PasswordDialog"
Title="Password Required"
Width="400" Height="180"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto" Margin="16">
<TextBlock Grid.Row="0"
Text="Enter password:"
FontSize="14"
Margin="0,0,0,8" />
<TextBox Grid.Row="1"
x:Name="TxtPassword"
PasswordChar="*"
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 VisionBuilder.UI.Avalonia.Uno.Views;
public partial class PasswordDialog : Window
{
public PasswordDialog()
{
InitializeComponent();
BtnOk.Click += (_, _) => Close(TxtPassword.Text);
BtnCancel.Click += (_, _) => Close(null);
TxtPassword.KeyDown += (_, e) =>
{
if (e.Key == Key.Enter)
Close(TxtPassword.Text);
else if (e.Key == Key.Escape)
Close(null);
};
}
}

View File

@@ -0,0 +1,49 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.Avalonia.Uno.Views.RecipeSelectionDialog"
Title="Select Recipe"
Width="800" Height="600"
WindowStartupLocation="CenterOwner"
Background="White">
<Grid RowDefinitions="*,Auto">
<ListBox x:Name="RecipeList"
Grid.Row="0"
Margin="8"
Background="White"
SelectionMode="Single">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="8" Spacing="8">
<Button x:Name="BtnCreateNew"
Content="CREATE NEW RECIPE"
IsVisible="False"
Height="48" Padding="16,0"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnCancel"
Content="CANCEL"
Height="48" Width="176" Padding="16,0"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,129 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using VisionBuilder.UI.Avalonia.Uno.Services;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Avalonia.Uno.Views;
public partial class RecipeSelectionDialog : global::Avalonia.Controls.Window
{
private readonly RecipeSelectionVM _recipeSelectionVm;
private readonly IRecipeCreationTool _recipeCreationTool;
private readonly TaskCompletionSource<bool> _tcs;
public RecipeSelectionDialog(RecipeSelectionVM recipeSelectionVm, IRecipeCreationTool recipeCreationTool, TaskCompletionSource<bool> tcs)
{
_recipeSelectionVm = recipeSelectionVm;
_recipeCreationTool = recipeCreationTool;
_tcs = tcs;
InitializeComponent();
if (_recipeCreationTool.Enabled)
{
BtnCreateNew.IsVisible = true;
}
BtnCancel.Click += (_, _) =>
{
_tcs.TrySetResult(false);
Close();
};
BtnCreateNew.Click += BtnCreateNew_Click;
Closed += (_, _) => _tcs.TrySetResult(false);
LoadRecipes();
}
private void LoadRecipes()
{
var recipes = _recipeSelectionVm.Recipes.OrderBy(x => x.RecipeName).ToList();
var items = new List<RecipeItem>();
foreach (var recipe in recipes)
{
Bitmap? thumbnail = null;
try
{
if (recipe.Image != null && !recipe.Image.Empty())
{
thumbnail = ImageConverter.MatToAvaloniaBitmap(
recipe.Image.Resize(new OpenCvSharp.Size(128, 128)));
}
}
catch
{
// Ignore conversion errors
}
items.Add(new RecipeItem(recipe, thumbnail));
}
RecipeList.ItemsSource = items;
RecipeList.ItemTemplate = new global::Avalonia.Controls.Templates.FuncDataTemplate<RecipeItem>((item, _) =>
{
var panel = new StackPanel
{
Width = 140,
Margin = new global::Avalonia.Thickness(4),
HorizontalAlignment = HorizontalAlignment.Center
};
var image = new Image
{
Source = item.Thumbnail,
Width = 128,
Height = 128,
Stretch = Stretch.Uniform
};
if (item.Thumbnail == null)
{
image.Source = null;
}
var text = new TextBlock
{
Text = item.Recipe.RecipeName,
HorizontalAlignment = HorizontalAlignment.Center,
TextTrimming = global::Avalonia.Media.TextTrimming.CharacterEllipsis,
FontSize = 12,
Margin = new global::Avalonia.Thickness(0, 4, 0, 0)
};
panel.Children.Add(image);
panel.Children.Add(text);
return panel;
});
RecipeList.SelectionChanged += RecipeList_SelectionChanged;
}
private void RecipeList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (RecipeList.SelectedItem is RecipeItem item)
{
_recipeSelectionVm.SelectedRecipe = item.Recipe;
_tcs.TrySetResult(true);
Close();
}
}
private void BtnCreateNew_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
if (_recipeCreationTool.CreateRecipe(out var recipeName))
{
_recipeSelectionVm.SelectedRecipe = new RecipeData { RecipeName = recipeName };
_tcs.TrySetResult(true);
Close();
}
}
private record RecipeItem(RecipeData Recipe, Bitmap? Thumbnail);
}

View File

@@ -0,0 +1,126 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:VisionBuilder.UI.Avalonia.Uno.Converters"
x:Class="VisionBuilder.UI.Avalonia.Uno.Views.SingleCameraView"
Background="White">
<UserControl.Resources>
<converters:MatToBitmapConverter x:Key="MatToBitmapConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVis" />
</UserControl.Resources>
<Grid ColumnDefinitions="5*,1,2*,1,Auto,Auto">
<!-- Column 0: Preview -->
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0"
x:Name="LblCameraName"
Text="Camera"
FontSize="16" FontWeight="SemiBold"
HorizontalAlignment="Center"
Margin="0,8,0,4" />
<Border Grid.Row="1" Margin="4"
x:Name="PreviewBorder"
BorderThickness="0"
BorderBrush="Red">
<Image x:Name="PreviewImage"
Stretch="Uniform" />
</Border>
</Grid>
<!-- Column 1: Divider -->
<Border Grid.Column="1" Background="#D32F2F" />
<!-- Column 2: Errors -->
<Grid Grid.Column="2" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0"
Text="Errors"
FontSize="16"
HorizontalAlignment="Center"
Margin="0,8,0,4" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="ErrorsList">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</ScrollViewer>
</Grid>
<!-- Column 3: Divider -->
<Border Grid.Column="3" Background="#D32F2F" />
<!-- Column 4: Stats -->
<ScrollViewer Grid.Column="4" Width="220" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="StatsPanel" Margin="8,8,8,0" Spacing="2" />
</ScrollViewer>
<!-- Column 5: Action buttons (right side) -->
<StackPanel x:Name="ButtonsPanel" Grid.Column="5" Width="180" Margin="8" Spacing="6"
VerticalAlignment="Top">
<Button x:Name="BtnSelectRecipe"
Content="SELECT RECIPE"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnStart"
Content="START"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
Background="#D32F2F" Foreground="White" />
<Button x:Name="BtnStop"
Content="STOP"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
Background="#D32F2F" Foreground="White" />
<Button x:Name="BtnPause"
Content="PAUSE"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnResume"
Content="RESUME"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<Button x:Name="BtnHotReload"
Content="HOT RELOAD"
Height="60"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="14" FontWeight="Bold"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,415 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using OpenCvSharp;
using VisionBuilder.UI.Avalonia.Uno.Services;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Avalonia.Uno.Views;
public partial class SingleCameraView : UserControl
{
private SingleCameraVM? _vm;
private ObservableCollection<ErrorData>? _subscribedErrors;
private readonly Dictionary<string, (TextBlock label, TextBlock value)> _statsFields = new();
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
private const string StartedAtLabel = "Started at";
private const string ProcessingTimeLabel = "Processing time";
private const string BadLabel = "Bad";
private const string TotalLabel = "Total";
private const string ErrorRateLabel = "Error rate";
private const string DetailsLabel = "Details";
public SingleCameraView()
{
InitializeComponent();
}
public void SetViewModel(SingleCameraVM singleCameraVm)
{
_vm = singleCameraVm;
_vm.SynchronizationContext = SynchronizationContext.Current;
DataContext = _vm;
// Bind commands
BtnSelectRecipe.Click += async (_, _) => await _vm.SelectRecipe();
BtnStart.Click += (_, _) => _vm.StartCommand.Execute(null);
BtnStop.Click += (_, _) => _vm.StopCommand.Execute(null);
BtnPause.Click += (_, _) => _vm.PauseCommand.Execute(null);
BtnResume.Click += (_, _) => _vm.ResumeCommand.Execute(null);
BtnHotReload.Click += (_, _) => _vm.HotReloadCommand.Execute(null);
// Subscribe to VM property changes
_vm.PropertyChanged += Vm_PropertyChanged;
_vm.PreviewVm.PropertyChanged += PreviewVm_PropertyChanged;
_vm.StatisticsVm.PropertyChanged += StatisticsVm_PropertyChanged;
// Subscribe to errors collection
_subscribedErrors = _vm.ErrorsVm.Errors;
_subscribedErrors.CollectionChanged += Errors_CollectionChanged;
// Initial UI state
UpdateButtonVisibility();
UpdateCameraLabel();
InitializeStatsFields();
// Subscribe to dynamic buttons
_vm.DynamicButtons.CollectionChanged += DynamicButtons_CollectionChanged;
foreach (var btn in _vm.DynamicButtons)
AddDynamicButton(btn);
}
private void Vm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
switch (e.PropertyName)
{
case nameof(SingleCameraVM.CameraLabelAndStatus):
UpdateCameraLabel();
break;
case nameof(SingleCameraVM.CurrentRecipeName):
BtnSelectRecipe.Content = _vm!.CurrentRecipeName.ToUpperInvariant();
break;
case nameof(SingleCameraVM.CanStart):
case nameof(SingleCameraVM.CanStop):
case nameof(SingleCameraVM.CanPause):
case nameof(SingleCameraVM.CanResume):
case nameof(SingleCameraVM.CanHotReload):
case nameof(SingleCameraVM.RecipeNotSelected):
UpdateButtonVisibility();
break;
}
});
}
private void UpdateCameraLabel()
{
LblCameraName.Text = _vm?.CameraLabelAndStatus ?? "Camera";
}
private void UpdateButtonVisibility()
{
if (_vm == null) return;
BtnStart.IsVisible = _vm.CanStart;
BtnStop.IsVisible = _vm.CanStop;
BtnPause.IsVisible = _vm.CanPause;
BtnResume.IsVisible = _vm.CanResume;
BtnHotReload.IsVisible = _vm.CanHotReload;
}
private void PreviewVm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PreviewVM.ImagePreview))
{
Dispatcher.UIThread.Post(() =>
{
var mat = _vm?.PreviewVm.ImagePreview;
if (mat != null && !mat.Empty())
{
PreviewImage.Source = ImageConverter.MatToAvaloniaBitmap(mat);
}
var isError = _vm?.PreviewVm.IsError ?? false;
PreviewBorder.BorderThickness = isError ? new Thickness(4) : new Thickness(0);
});
}
}
#region Statistics
private void InitializeStatsFields()
{
StatsPanel.Children.Clear();
_statsFields.Clear();
UpdateStat(StartedAtLabel, "");
UpdateStat(ProcessingTimeLabel, "");
UpdateStat(TotalLabel, "0");
UpdateStat(BadLabel, "0");
UpdateStat(ErrorRateLabel, "0");
UpdateStat(DetailsLabel, "");
}
private void StatisticsVm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
if (_vm == null) return;
var stats = _vm.StatisticsVm;
switch (e.PropertyName)
{
case nameof(StatisticsVM.SessionStarted):
InitializeStatsFields();
UpdateStat(StartedAtLabel, stats.SessionStarted);
break;
case nameof(StatisticsVM.ProcessingTime):
UpdateStat(ProcessingTimeLabel, stats.ProcessingTime);
break;
case nameof(StatisticsVM.Bad):
UpdateStat(BadLabel, stats.Bad.ToString());
break;
case nameof(StatisticsVM.Total):
UpdateStat(TotalLabel, stats.Total.ToString());
break;
case nameof(StatisticsVM.ErrorRate):
UpdateStat(ErrorRateLabel, stats.ErrorRate);
break;
case nameof(StatisticsVM.StatisticsDetails):
UpdateStat(DetailsLabel, stats.StatisticsDetails);
break;
}
});
}
private void UpdateStat(string name, string text)
{
if (!_statsFields.ContainsKey(name))
{
var label = new TextBlock
{
Text = name + ":",
FontWeight = FontWeight.Bold,
FontSize = 12,
Margin = new Thickness(0, 6, 0, 0)
};
var value = new TextBlock
{
Text = text,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0)
};
StatsPanel.Children.Add(label);
StatsPanel.Children.Add(value);
_statsFields[name] = (label, value);
}
else
{
_statsFields[name].value.Text = text;
}
}
#endregion
#region Errors
private void Errors_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
if (e.NewItems != null)
{
foreach (ErrorData error in e.NewItems)
AddErrorThumbnail(error);
}
break;
case NotifyCollectionChangedAction.Remove:
if (e.OldItems != null)
{
foreach (ErrorData error in e.OldItems)
RemoveErrorThumbnail(error);
}
break;
case NotifyCollectionChangedAction.Reset:
ErrorsList.ItemsSource = null;
_errorControls.Clear();
break;
}
});
}
private readonly Dictionary<ErrorData, Control> _errorControls = new();
private void AddErrorThumbnail(ErrorData errorData)
{
var panel = new StackPanel
{
Width = 183,
Margin = new Thickness(4),
Cursor = new Cursor(StandardCursorType.Hand)
};
Bitmap? thumbnail = null;
try
{
if (errorData.ImageAnalysis != null && !errorData.ImageAnalysis.Empty())
{
thumbnail = ImageConverter.MatToAvaloniaBitmap(
errorData.ImageAnalysis.Resize(new OpenCvSharp.Size(183, 138)));
}
}
catch
{
// Ignore conversion errors
}
var image = new Image
{
Source = thumbnail,
Height = 138,
Stretch = Stretch.Uniform
};
var text = new TextBlock
{
Text = errorData.Title,
FontSize = 11,
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis
};
panel.Children.Add(image);
panel.Children.Add(text);
panel.PointerPressed += (_, _) =>
{
_vm?.ErrorsVm.ShowImage(errorData);
};
_errorControls[errorData] = panel;
// Insert at the beginning (newest first)
if (ErrorsList.ItemsSource == null)
{
var items = new ObservableCollection<Control>();
ErrorsList.ItemsSource = items;
}
if (ErrorsList.ItemsSource is ObservableCollection<Control> collection)
{
collection.Insert(0, panel);
}
}
private void RemoveErrorThumbnail(ErrorData errorData)
{
if (_errorControls.TryGetValue(errorData, out var control))
{
if (ErrorsList.ItemsSource is ObservableCollection<Control> collection)
{
collection.Remove(control);
}
_errorControls.Remove(errorData);
}
}
#endregion
#region Dynamic Buttons
private void DynamicButtons_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (ButtonDefinition btn in e.NewItems!)
AddDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Remove:
foreach (ButtonDefinition btn in e.OldItems!)
RemoveDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var ctrl in _dynamicButtonControls.Values)
ButtonsPanel.Children.Remove(ctrl);
_dynamicButtonControls.Clear();
break;
}
});
}
private void AddDynamicButton(ButtonDefinition buttonDef)
{
Control control;
if (buttonDef.Type == EButtonType.Toggle)
{
var toggleBtn = new ToggleButton
{
Content = buttonDef.Title.ToUpperInvariant(),
Height = 60,
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
HorizontalContentAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
VerticalContentAlignment = global::Avalonia.Layout.VerticalAlignment.Center,
FontSize = 14,
FontWeight = FontWeight.Bold,
IsChecked = buttonDef.IsToggled,
IsVisible = buttonDef.IsVisible,
BorderBrush = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F)),
BorderThickness = new Thickness(2),
Background = Brushes.White,
Foreground = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F))
};
toggleBtn.Click += (_, _) => buttonDef.Execute();
buttonDef.PropertyChanged += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
toggleBtn.IsVisible = buttonDef.IsVisible;
else if (args.PropertyName == nameof(ButtonDefinition.IsToggled))
toggleBtn.IsChecked = buttonDef.IsToggled;
});
};
control = toggleBtn;
}
else
{
var btn = new Button
{
Content = buttonDef.Title.ToUpperInvariant(),
Height = 60,
HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
HorizontalContentAlignment = global::Avalonia.Layout.HorizontalAlignment.Center,
VerticalContentAlignment = global::Avalonia.Layout.VerticalAlignment.Center,
FontSize = 14,
FontWeight = FontWeight.Bold,
IsVisible = buttonDef.IsVisible,
BorderBrush = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F)),
BorderThickness = new Thickness(2),
Background = Brushes.White,
Foreground = new SolidColorBrush(Color.FromRgb(0xD3, 0x2F, 0x2F))
};
btn.Click += (_, _) => buttonDef.Execute();
buttonDef.PropertyChanged += (_, args) =>
{
Dispatcher.UIThread.Post(() =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
btn.IsVisible = buttonDef.IsVisible;
});
};
control = btn;
}
ButtonsPanel.Children.Add(control);
_dynamicButtonControls[buttonDef.Key] = control;
}
private void RemoveDynamicButton(ButtonDefinition buttonDef)
{
if (_dynamicButtonControls.TryGetValue(buttonDef.Key, out var ctrl))
{
ButtonsPanel.Children.Remove(ctrl);
_dynamicButtonControls.Remove(buttonDef.Key);
}
}
#endregion
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!--<RuntimeIdentifier>linux-arm64</RuntimeIdentifier>-->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Deterministic>false</Deterministic>
<Version>2.0.14</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="Avalonia.LinuxFramebuffer" Version="11.2.3" />
<PackageReference Include="Ninject.Extensions.ChildKernel" Version="3.3.0" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
<PackageReference Include="swety.OpenCvSharp4.runtime.linux-arm64" Version="4.10.0.20241108" />
<!-- Override transitive GPU OnnxRuntime with CPU-only (GPU has no linux-arm64 natives) -->
<PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu" Version="1.18.0" ExcludeAssets="all" />
<PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu.Windows" Version="1.18.0" ExcludeAssets="all" />
<PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu.Linux" Version="1.18.0" ExcludeAssets="all" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.18.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\framework\Inspectron.Settings\Inspectron.Settings.csproj" />
<ProjectReference Include="..\Hawkeye.VisionBuilder.UI.Sources.Emulation\Hawkeye.VisionBuilder.UI.Sources.Emulation.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Camera\VisionBuilder.UI.Camera.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.IOCommander\VisionBuilder.UI.IOCommander.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Recipes.HawkeyeRecipe\VisionBuilder.UI.Recipes.HawkeyeRecipe.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Ringbuffer\VisionBuilder.UI.Ringbuffer.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Statistics\VisionBuilder.UI.Statistics.csproj" />
</ItemGroup>
</Project>

View File

@@ -33,46 +33,41 @@
OnClick="@OnSelectRecipe">
@_currentRecipeName
</MudButton>
@if (_canStart)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Style="@(_canStart ? null : "display:none")"
Disabled="@(!_canStart)"
OnClick="@OnStart">
Start
</MudButton>
}
@if (_canStop)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Style="@(_canStop ? null : "display:none")"
Disabled="@(!_canStop)"
OnClick="@OnStop">
Stop
</MudButton>
}
@if (_showPauseButton && _canPause)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Style="@(_showPauseButton && _canPause ? null : "display:none")"
Disabled="@(!_canPause)"
OnClick="@OnPause">
Pause
</MudButton>
}
@if (_showPauseButton && _canResume)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Style="@(_showPauseButton && _canResume ? null : "display:none")"
Disabled="@(!_canResume)"
OnClick="@OnResume">
Resume
</MudButton>
}
@if (_canHotReload)
{
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true"
Class="camera-btn"
Style="@(_canHotReload ? null : "display:none")"
Disabled="@(!_canHotReload)"
OnClick="@OnHotReload">
Hot Reload
</MudButton>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace VisionBuilder.UI.Common.ViewModel.Classes;
public enum EButtonType
{
Click,
Toggle
}
public partial class ButtonDefinition : ObservableObject
{
public string Key { get; }
public string Title { get; }
public EButtonType Type { get; }
[ObservableProperty]
private bool _isVisible = true;
[ObservableProperty]
private bool _isToggled;
private readonly Action? _clickCallback;
private readonly Action<bool>? _toggleCallback;
public ButtonDefinition(string key, string title, Action clickCallback)
{
Key = key;
Title = title;
Type = EButtonType.Click;
_clickCallback = clickCallback;
}
public ButtonDefinition(string key, string title, Action<bool> toggleCallback, bool initialState = false)
{
Key = key;
Title = title;
Type = EButtonType.Toggle;
_toggleCallback = toggleCallback;
_isToggled = initialState;
}
public void Execute()
{
if (Type == EButtonType.Click)
{
_clickCallback?.Invoke();
}
else
{
IsToggled = !IsToggled;
_toggleCallback?.Invoke(IsToggled);
}
}
}

View File

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using VisionBuilder.UI.Common.Commands;
using VisionBuilder.UI.Common.Commands.Interfaces;
using VisionBuilder.UI.Common.ViewModel.Classes;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Common.ViewModel;
@@ -25,6 +26,27 @@ public partial class MainWindowVM:ObservableObject
public ObservableCollection<SingleCameraVM> SingleCameraVms { get; set; } =
new ObservableCollection<SingleCameraVM>();
public ObservableCollection<ButtonDefinition> DynamicButtons { get; } = new();
public void AddButton(ButtonDefinition button)
{
DynamicButtons.Add(button);
}
public void SetButtonVisibility(string key, bool isVisible)
{
var button = DynamicButtons.FirstOrDefault(b => b.Key == key);
if (button != null)
button.IsVisible = isVisible;
}
public void RemoveButton(string key)
{
var button = DynamicButtons.FirstOrDefault(b => b.Key == key);
if (button != null)
DynamicButtons.Remove(button);
}
[RelayCommand]
public async Task Settings()

View File

@@ -24,6 +24,27 @@ namespace VisionBuilder.UI.Common
public StatisticsVM StatisticsVm { get; private set; }
public PreviewVM PreviewVm { get; private set; }
public ObservableCollection<ButtonDefinition> DynamicButtons { get; } = new();
public void AddButton(ButtonDefinition button)
{
DynamicButtons.Add(button);
}
public void SetButtonVisibility(string key, bool isVisible)
{
var button = DynamicButtons.FirstOrDefault(b => b.Key == key);
if (button != null)
button.IsVisible = isVisible;
}
public void RemoveButton(string key)
{
var button = DynamicButtons.FirstOrDefault(b => b.Key == key);
if (button != null)
DynamicButtons.Remove(button);
}
[ObservableProperty]
private string _currentRecipeName = "Select recipe";
@@ -147,8 +168,6 @@ namespace VisionBuilder.UI.Common
public async Task Start()
{
IsRunning = true;
await Task.Run(() => { _recognitionControl.Start(); });
_handlingEnabled = true;

View File

@@ -0,0 +1,7 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.RaspberryAcquisition.App">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,28 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using VisionBuilder.UI.RaspberryAcquisition.ViewModels;
using VisionBuilder.UI.RaspberryAcquisition.Views;
namespace VisionBuilder.UI.RaspberryAcquisition;
public class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel()
};
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -0,0 +1,10 @@
namespace VisionBuilder.UI.RaspberryAcquisition.Models;
public class CalibrationData
{
public double[] CameraMatrix { get; set; } = [];
public double[] DistCoeffs { get; set; } = [];
public double RmsError { get; set; }
public int ImageWidth { get; set; }
public int ImageHeight { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace VisionBuilder.UI.RaspberryAcquisition.Models;
public class CaptureSettings
{
public int Width { get; set; } = 2028;
public int Height { get; set; } = 1520;
public int ShutterSpeed { get; set; } = 10000;
public int Framerate { get; set; } = 2;
public double AwbGainRed { get; set; } = 3.86;
public double AwbGainBlue { get; set; } = 1.46;
public string BuildArguments()
{
return $"--codec mjpeg -t0 --width {Width} --height {Height} " +
$"--shutter {ShutterSpeed} --framerate {Framerate} " +
$"--awbgains {AwbGainRed:F2},{AwbGainBlue:F2} --nopreview -o -";
}
}

View File

@@ -0,0 +1,21 @@
using Avalonia;
using Avalonia.LinuxFramebuffer;
namespace VisionBuilder.UI.RaspberryAcquisition;
public class Program
{
[STAThread]
public static void Main(string[] args)
{
if (args.Contains("--drm"))
BuildAvaloniaApp().StartLinuxDrm(args, card: null, scaling: 1.0);
else
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
}

View File

@@ -0,0 +1,116 @@
using System.Text.Json;
using OpenCvSharp;
using VisionBuilder.UI.RaspberryAcquisition.Models;
namespace VisionBuilder.UI.RaspberryAcquisition.Services;
public static class CameraCalibrationService
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static CalibrationData Calibrate(string imageDirectory, Size patternSize)
{
var imageFiles = Directory.GetFiles(imageDirectory, "*.png");
if (imageFiles.Length == 0)
throw new InvalidOperationException($"No PNG images found in {imageDirectory}");
var objectPointsList = new List<Mat>();
var imagePointsList = new List<Mat>();
Size imageSize = default;
// Build the 3D object points for the checkerboard (z=0 plane)
int cornerCount = patternSize.Width * patternSize.Height;
var objPts = new Point3f[cornerCount];
for (int row = 0; row < patternSize.Height; row++)
for (int col = 0; col < patternSize.Width; col++)
objPts[row * patternSize.Width + col] = new Point3f(col, row, 0);
int found = 0;
foreach (var file in imageFiles)
{
using var image = Cv2.ImRead(file);
using var gray = new Mat();
Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY);
imageSize = new Size(image.Width, image.Height);
if (Cv2.FindChessboardCorners(gray, patternSize, out var corners,
ChessboardFlags.AdaptiveThresh | ChessboardFlags.FastCheck))
{
Cv2.CornerSubPix(gray, corners,
new Size(11, 11), new Size(-1, -1),
new TermCriteria(CriteriaTypes.Eps | CriteriaTypes.MaxIter, 30, 0.001));
// Convert to Mat for CalibrateCamera
var objMat = new Mat(cornerCount, 1, MatType.CV_32FC3);
for (int i = 0; i < cornerCount; i++)
objMat.Set(i, 0, objPts[i]);
objectPointsList.Add(objMat);
var imgMat = new Mat(corners.Length, 1, MatType.CV_32FC2);
for (int i = 0; i < corners.Length; i++)
imgMat.Set(i, 0, corners[i]);
imagePointsList.Add(imgMat);
found++;
}
}
if (found == 0)
throw new InvalidOperationException("Checkerboard corners not found in any image");
var cameraMatrix = new Mat();
var distCoeffs = new Mat();
double rms = Cv2.CalibrateCamera(
objectPointsList, imagePointsList, imageSize,
cameraMatrix, distCoeffs,
out _, out _);
var data = new CalibrationData
{
CameraMatrix = MatToArray(cameraMatrix),
DistCoeffs = MatToArray(distCoeffs),
RmsError = rms,
ImageWidth = imageSize.Width,
ImageHeight = imageSize.Height
};
cameraMatrix.Dispose();
distCoeffs.Dispose();
foreach (var m in objectPointsList) m.Dispose();
foreach (var m in imagePointsList) m.Dispose();
return data;
}
public static void SaveCalibration(CalibrationData data, string path)
{
var json = JsonSerializer.Serialize(data, JsonOptions);
File.WriteAllText(path, json);
}
public static (Mat cameraMatrix, Mat distCoeffs) LoadCalibration(string path)
{
var json = File.ReadAllText(path);
var data = JsonSerializer.Deserialize<CalibrationData>(json)
?? throw new InvalidOperationException("Failed to deserialize calibration data");
var cameraMatrix = new Mat(3, 3, MatType.CV_64F);
for (int i = 0; i < 9; i++)
cameraMatrix.Set(i / 3, i % 3, data.CameraMatrix[i]);
var distCoeffs = new Mat(1, data.DistCoeffs.Length, MatType.CV_64F);
for (int i = 0; i < data.DistCoeffs.Length; i++)
distCoeffs.Set(0, i, data.DistCoeffs[i]);
return (cameraMatrix, distCoeffs);
}
private static double[] MatToArray(Mat mat)
{
var total = mat.Rows * mat.Cols;
var arr = new double[total];
for (int i = 0; i < total; i++)
arr[i] = mat.At<double>(i / mat.Cols, i % mat.Cols);
return arr;
}
}

View File

@@ -0,0 +1,163 @@
using System.Diagnostics;
using System.Threading.Channels;
using OpenCvSharp;
using VisionBuilder.UI.Common.Processing;
using VisionBuilder.UI.RaspberryAcquisition.Models;
namespace VisionBuilder.UI.RaspberryAcquisition.Services;
public class ConfigurableLibcameraSource : IImageSource, IDisposable
{
private static readonly byte[] JpegHeader = [0xff, 0xd8];
private static readonly byte[] JpegFooter = [0xff, 0xd9];
private const int ChunkSize = 1024;
private readonly Channel<Mat> _imageChannel = Channel.CreateBounded<Mat>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
private CaptureSettings _settings;
private CancellationTokenSource? _cts;
private Thread? _captureThread;
public int FrameCount { get; private set; }
public event Action<string>? StatusChanged;
public ConfigurableLibcameraSource(CaptureSettings settings)
{
_settings = settings;
}
public Task<Mat> GetImage(CancellationToken token)
{
return _imageChannel.Reader.ReadAsync(token).AsTask();
}
public void Start()
{
Stop();
FrameCount = 0;
_cts = new CancellationTokenSource();
_captureThread = new Thread(CaptureLoop) { IsBackground = true };
_captureThread.Start();
StatusChanged?.Invoke("Running");
}
public void Stop()
{
_cts?.Cancel();
_captureThread?.Join(2000);
_cts?.Dispose();
_cts = null;
_captureThread = null;
StatusChanged?.Invoke("Stopped");
}
public void UpdateSettings(CaptureSettings newSettings)
{
_settings = newSettings;
Start();
}
private void CaptureLoop()
{
var psi = new ProcessStartInfo
{
FileName = "rpicam-vid",
Arguments = _settings.BuildArguments(),
RedirectStandardOutput = true,
UseShellExecute = false
};
Process? process = null;
try
{
process = Process.Start(psi);
if (process == null)
{
StatusChanged?.Invoke("Error: failed to start rpicam-vid");
return;
}
using var br = new BinaryReader(process.StandardOutput.BaseStream);
var imageBuffer = new byte[1024 * 1024];
var buff = br.ReadBytes(ChunkSize);
while (!_cts!.Token.IsCancellationRequested)
{
var imageStart = Find(buff, JpegHeader);
if (imageStart == -1)
{
StatusChanged?.Invoke("Error: JPEG header not found");
break;
}
var size = buff.Length - imageStart;
Array.Copy(buff, imageStart, imageBuffer, 0, size);
while (!_cts.Token.IsCancellationRequested)
{
buff = br.ReadBytes(ChunkSize);
var imageEnd = Find(buff, JpegFooter);
if (imageEnd != -1)
{
imageEnd += JpegFooter.Length;
var frame = new byte[size + imageEnd];
Array.Copy(imageBuffer, frame, size);
Array.Copy(buff, 0, frame, size, imageEnd);
var decoded = Mat.ImDecode(frame);
FrameCount++;
_imageChannel.Writer.TryWrite(decoded);
var remaining = buff.Length - imageEnd;
var newBuff = new byte[ChunkSize];
Array.Copy(buff, imageEnd, newBuff, 0, remaining);
var temp = br.ReadBytes(imageEnd);
Array.Copy(temp, 0, newBuff, remaining, temp.Length);
buff = newBuff;
break;
}
Array.Copy(buff, 0, imageBuffer, size, buff.Length);
size += buff.Length;
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
StatusChanged?.Invoke($"Error: {ex.Message}");
}
finally
{
if (process is { HasExited: false })
{
process.Kill();
process.Dispose();
}
}
}
private static int Find(byte[] buff, byte[] search)
{
for (int start = 0; start <= buff.Length - search.Length; start++)
{
if (buff[start] == search[0])
{
int next;
for (next = 1; next < search.Length; next++)
{
if (buff[start + next] != search[next])
break;
}
if (next == search.Length)
return start;
}
}
return -1;
}
public void Dispose()
{
Stop();
}
}

View File

@@ -0,0 +1,13 @@
using OpenCvSharp;
namespace VisionBuilder.UI.RaspberryAcquisition.Services;
public static class ImageConverter
{
public static Avalonia.Media.Imaging.Bitmap MatToAvaloniaBitmap(Mat mat)
{
var encoded = mat.ImEncode(".bmp");
using var ms = new MemoryStream(encoded);
return new Avalonia.Media.Imaging.Bitmap(ms);
}
}

View File

@@ -0,0 +1,206 @@
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OpenCvSharp;
using VisionBuilder.UI.RaspberryAcquisition.Models;
using VisionBuilder.UI.RaspberryAcquisition.Services;
namespace VisionBuilder.UI.RaspberryAcquisition.ViewModels;
public partial class MainWindowViewModel : ObservableObject, IDisposable
{
private const string CalibrationFile = "calibration.json";
private ConfigurableLibcameraSource? _source;
private CancellationTokenSource? _previewCts;
private Mat? _lastFrame;
private Mat? _cameraMatrix;
private Mat? _distCoeffs;
[ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage;
[ObservableProperty] private string _statusText = "Stopped";
[ObservableProperty] private int _frameCount;
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _isCalibrated;
[ObservableProperty] private string _width = "2028";
[ObservableProperty] private string _height = "1520";
[ObservableProperty] private string _shutterSpeed = "10000";
[ObservableProperty] private string _framerate = "2";
[ObservableProperty] private string _awbGainRed = "3.86";
[ObservableProperty] private string _awbGainBlue = "1.46";
[ObservableProperty] private string _saveDirectory = "/home/oem/captures";
[ObservableProperty] private string _calibrationDirectory = "/home/oem/captures";
public MainWindowViewModel()
{
TryLoadCalibration();
}
private void TryLoadCalibration()
{
if (!File.Exists(CalibrationFile)) return;
try
{
(_cameraMatrix, _distCoeffs) = CameraCalibrationService.LoadCalibration(CalibrationFile);
IsCalibrated = true;
StatusText = "Calibration loaded";
}
catch (Exception ex)
{
StatusText = $"Calibration load error: {ex.Message}";
}
}
[RelayCommand]
private void StartCapture()
{
if (IsRunning) return;
var settings = BuildSettings();
if (settings == null) return;
_source = new ConfigurableLibcameraSource(settings);
_source.StatusChanged += status =>
Dispatcher.UIThread.Post(() => StatusText = status);
_source.Start();
IsRunning = true;
_previewCts = new CancellationTokenSource();
_ = PreviewLoopAsync(_previewCts.Token);
}
[RelayCommand]
private void StopCapture()
{
if (!IsRunning) return;
_previewCts?.Cancel();
_source?.Stop();
_source?.Dispose();
_source = null;
IsRunning = false;
StatusText = "Stopped";
}
[RelayCommand]
private void ApplySettings()
{
if (!IsRunning || _source == null) return;
var settings = BuildSettings();
if (settings == null) return;
_source.UpdateSettings(settings);
}
[RelayCommand]
private void SaveImage()
{
if (_lastFrame == null || _lastFrame.Empty()) return;
Directory.CreateDirectory(SaveDirectory);
var filename = $"capture_{DateTime.Now:yyyyMMdd_HHmmss}.png";
var path = Path.Combine(SaveDirectory, filename);
Cv2.ImWrite(path, _lastFrame);
StatusText = $"Saved: {filename}";
}
[RelayCommand]
private void CalibrateCamera()
{
try
{
StatusText = "Calibrating...";
var data = CameraCalibrationService.Calibrate(CalibrationDirectory, new Size(9, 6));
CameraCalibrationService.SaveCalibration(data, CalibrationFile);
_cameraMatrix?.Dispose();
_distCoeffs?.Dispose();
(_cameraMatrix, _distCoeffs) = CameraCalibrationService.LoadCalibration(CalibrationFile);
IsCalibrated = true;
StatusText = $"Calibrated (RMS: {data.RmsError:F4})";
}
catch (Exception ex)
{
StatusText = $"Calibration error: {ex.Message}";
}
}
private async Task PreviewLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested && _source != null)
{
try
{
var frame = await _source.GetImage(ct);
if (_cameraMatrix != null && _distCoeffs != null)
{
var corrected = new Mat();
Cv2.Undistort(frame, corrected, _cameraMatrix, _distCoeffs);
frame.Dispose();
frame = corrected;
}
_lastFrame?.Dispose();
_lastFrame = frame;
var bitmap = ImageConverter.MatToAvaloniaBitmap(frame);
await Dispatcher.UIThread.InvokeAsync(() =>
{
var old = PreviewImage;
PreviewImage = bitmap;
FrameCount = _source?.FrameCount ?? 0;
old?.Dispose();
});
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
StatusText = $"Preview error: {ex.Message}");
break;
}
}
}
private CaptureSettings? BuildSettings()
{
if (!int.TryParse(Width, out var w) ||
!int.TryParse(Height, out var h) ||
!int.TryParse(ShutterSpeed, out var s) ||
!int.TryParse(Framerate, out var f) ||
!double.TryParse(AwbGainRed, CultureInfo.InvariantCulture, out var r) ||
!double.TryParse(AwbGainBlue, CultureInfo.InvariantCulture, out var b))
{
StatusText = "Error: invalid settings values";
return null;
}
return new CaptureSettings
{
Width = w,
Height = h,
ShutterSpeed = s,
Framerate = f,
AwbGainRed = r,
AwbGainBlue = b
};
}
public void Dispose()
{
StopCapture();
_lastFrame?.Dispose();
_cameraMatrix?.Dispose();
_distCoeffs?.Dispose();
}
}

View File

@@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:VisionBuilder.UI.RaspberryAcquisition.ViewModels"
x:Class="VisionBuilder.UI.RaspberryAcquisition.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="RPi Image Acquisition"
Width="1024" Height="800">
<DockPanel>
<!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8">
<Button Content="Start" Command="{Binding StartCaptureCommand}" IsEnabled="{Binding !IsRunning}" />
<Button Content="Stop" Command="{Binding StopCaptureCommand}" IsEnabled="{Binding IsRunning}" />
<Button Content="Save Image" Command="{Binding SaveImageCommand}" />
<Button Content="Apply Settings" Command="{Binding ApplySettingsCommand}" IsEnabled="{Binding IsRunning}" />
</StackPanel>
<!-- Status Bar -->
<Border DockPanel.Dock="Bottom" Background="#1E1E1E" Padding="8,4">
<StackPanel Orientation="Horizontal" Spacing="16">
<TextBlock Text="{Binding StatusText}" Foreground="LightGray" />
<TextBlock Foreground="LightGray">
<TextBlock.Text>
<MultiBinding StringFormat="Frames: {0}">
<Binding Path="FrameCount" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Border>
<!-- Settings Panel -->
<Border DockPanel.Dock="Right" Width="220" Padding="12" Background="#2D2D2D">
<ScrollViewer>
<StackPanel Spacing="8">
<TextBlock Text="Camera Settings" FontWeight="Bold" FontSize="14" Foreground="White" />
<TextBlock Text="Width" Foreground="LightGray" />
<TextBox Text="{Binding Width}" />
<TextBlock Text="Height" Foreground="LightGray" />
<TextBox Text="{Binding Height}" />
<TextBlock Text="Shutter (µs)" Foreground="LightGray" />
<TextBox Text="{Binding ShutterSpeed}" />
<TextBlock Text="Framerate" Foreground="LightGray" />
<TextBox Text="{Binding Framerate}" />
<TextBlock Text="AWB Gain Red" Foreground="LightGray" />
<TextBox Text="{Binding AwbGainRed}" />
<TextBlock Text="AWB Gain Blue" Foreground="LightGray" />
<TextBox Text="{Binding AwbGainBlue}" />
<Separator Margin="0,8" />
<TextBlock Text="Save Directory" Foreground="LightGray" />
<TextBox Text="{Binding SaveDirectory}" />
<Separator Margin="0,8" />
<TextBlock Text="Calibration" FontWeight="Bold" FontSize="14" Foreground="White" />
<TextBlock Text="Calibration Dir" Foreground="LightGray" />
<TextBox Text="{Binding CalibrationDirectory}" />
<Button Content="Calibrate" Command="{Binding CalibrateCameraCommand}" Margin="0,4" />
<TextBlock Text="Calibrated" Foreground="LimeGreen" IsVisible="{Binding IsCalibrated}" />
</StackPanel>
</ScrollViewer>
</Border>
<!-- Live Preview -->
<Border Background="#1A1A1A" Margin="4">
<Image Source="{Binding PreviewImage}" Stretch="Uniform" />
</Border>
</DockPanel>
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace VisionBuilder.UI.RaspberryAcquisition.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifiers>linux-arm64</RuntimeIdentifiers>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="Avalonia.LinuxFramebuffer" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="swety.OpenCvSharp4.runtime.linux-arm64" Version="4.10.0.20241108" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -67,8 +67,8 @@ namespace VisionBuilder.UI.Recipes.HawkeyeRecipe
protected override void Initialize(RecipeData currentRecipe)
{
var path = "..\\Data\\Recipes\\" + currentRecipe.RecipeName + ".jhrcp";
_currentRecipeFilePath = Path.GetFullPath(path);
var path = Path.GetFullPath(Path.Combine("..", "Data", "Recipes", currentRecipe.RecipeName + ".jhrcp"));
_currentRecipeFilePath = path;
_workflow = WorkflowList.LoadJSONFromFile(path);
StartFileWatcher(_currentRecipeFilePath);
}

View File

@@ -0,0 +1,170 @@
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Tests;
[TestClass]
public sealed class DynamicButtonTests
{
private class StubSettingsService : ISettingsService
{
public Task ShowSettingsDialogAsync() => Task.CompletedTask;
}
#region ButtonDefinition Tests
[TestMethod]
public void ClickButton_Execute_InvokesCallback()
{
bool called = false;
var button = new ButtonDefinition("key1", "Test", () => called = true);
button.Execute();
Assert.IsTrue(called);
}
[TestMethod]
public void ClickButton_HasCorrectProperties()
{
var button = new ButtonDefinition("myKey", "My Title", () => { });
Assert.AreEqual("myKey", button.Key);
Assert.AreEqual("My Title", button.Title);
Assert.AreEqual(EButtonType.Click, button.Type);
}
[TestMethod]
public void ToggleButton_Execute_TogglesAndInvokesCallback()
{
bool? receivedState = null;
var button = new ButtonDefinition("key1", "Test", (state) => receivedState = state);
button.Execute();
Assert.IsTrue(button.IsToggled);
Assert.AreEqual(true, receivedState);
}
[TestMethod]
public void ToggleButton_ExecuteTwice_TogglesBack()
{
bool? receivedState = null;
var button = new ButtonDefinition("key1", "Test", (state) => receivedState = state);
button.Execute();
button.Execute();
Assert.IsFalse(button.IsToggled);
Assert.AreEqual(false, receivedState);
}
[TestMethod]
public void ToggleButton_InitialState_IsRespected()
{
var button = new ButtonDefinition("key1", "Test", (_) => { }, initialState: true);
Assert.IsTrue(button.IsToggled);
Assert.AreEqual(EButtonType.Toggle, button.Type);
}
[TestMethod]
public void ButtonDefinition_IsVisible_DefaultTrue()
{
var button = new ButtonDefinition("key1", "Test", () => { });
Assert.IsTrue(button.IsVisible);
}
[TestMethod]
public void ButtonDefinition_SetVisibility_RaisesPropertyChanged()
{
var button = new ButtonDefinition("key1", "Test", () => { });
string? changedProperty = null;
button.PropertyChanged += (_, args) => changedProperty = args.PropertyName;
button.IsVisible = false;
Assert.AreEqual(nameof(ButtonDefinition.IsVisible), changedProperty);
Assert.IsFalse(button.IsVisible);
}
[TestMethod]
public void ToggleButton_Execute_RaisesToggledPropertyChanged()
{
var button = new ButtonDefinition("key1", "Test", (_) => { });
string? changedProperty = null;
button.PropertyChanged += (_, args) => changedProperty = args.PropertyName;
button.Execute();
Assert.AreEqual(nameof(ButtonDefinition.IsToggled), changedProperty);
}
#endregion
#region MainWindowVM Tests
[TestMethod]
public void MainWindowVM_AddButton_AddsToCollection()
{
var vm = new MainWindowVM(new StubSettingsService());
var button = new ButtonDefinition("key1", "Test", () => { });
vm.AddButton(button);
Assert.AreEqual(1, vm.DynamicButtons.Count);
Assert.AreSame(button, vm.DynamicButtons[0]);
}
[TestMethod]
public void MainWindowVM_SetButtonVisibility_UpdatesButton()
{
var vm = new MainWindowVM(new StubSettingsService());
var button = new ButtonDefinition("key1", "Test", () => { });
vm.AddButton(button);
vm.SetButtonVisibility("key1", false);
Assert.IsFalse(button.IsVisible);
}
[TestMethod]
public void MainWindowVM_SetButtonVisibility_NonExistentKey_DoesNothing()
{
var vm = new MainWindowVM(new StubSettingsService());
var button = new ButtonDefinition("key1", "Test", () => { });
vm.AddButton(button);
vm.SetButtonVisibility("nonexistent", false);
Assert.IsTrue(button.IsVisible);
}
[TestMethod]
public void MainWindowVM_RemoveButton_RemovesFromCollection()
{
var vm = new MainWindowVM(new StubSettingsService());
var button = new ButtonDefinition("key1", "Test", () => { });
vm.AddButton(button);
vm.RemoveButton("key1");
Assert.AreEqual(0, vm.DynamicButtons.Count);
}
[TestMethod]
public void MainWindowVM_RemoveButton_NonExistentKey_DoesNothing()
{
var vm = new MainWindowVM(new StubSettingsService());
var button = new ButtonDefinition("key1", "Test", () => { });
vm.AddButton(button);
vm.RemoveButton("nonexistent");
Assert.AreEqual(1, vm.DynamicButtons.Count);
}
#endregion
}

View File

@@ -1,8 +1,10 @@
using System.Collections.Specialized;
using MaterialSkin;
using MaterialSkin.Controls;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Services;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Windows.Duo
{
@@ -12,6 +14,7 @@ namespace VisionBuilder.UI.Windows.Duo
private readonly IPasswordInputService _passwordInputService;
private readonly UIConfiguration _uiConfiguration;
private readonly WindowsUISettings _windowsUiSettings;
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
public Form1(MainWindowVM mainWindowVm, IPasswordInputService passwordInputService, UIConfiguration uiConfiguration, WindowsUISettings windowsUiSettings)
{
@@ -30,6 +33,9 @@ namespace VisionBuilder.UI.Windows.Duo
if(!_uiConfiguration.ShowTestModeButton)
flowLayoutPanel1.Controls.Remove(btnTestMode);
_mainWindowVm.DynamicButtons.CollectionChanged += DynamicButtons_CollectionChanged;
foreach (var btn in _mainWindowVm.DynamicButtons)
AddDynamicButton(btn);
}
private void _mainWindowVm_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -105,5 +111,69 @@ namespace VisionBuilder.UI.Windows.Duo
{
return this;
}
private void DynamicButtons_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (ButtonDefinition btn in e.NewItems!)
AddDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Remove:
foreach (ButtonDefinition btn in e.OldItems!)
RemoveDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var ctrl in _dynamicButtonControls.Values)
flowLayoutPanel1.Controls.Remove(ctrl);
_dynamicButtonControls.Clear();
break;
}
}
private void AddDynamicButton(ButtonDefinition buttonDef)
{
var btn = new MaterialRaisedButton
{
Text = buttonDef.Title,
Size = new Size(176, 64),
Font = new Font("Segoe UI Semibold", 12F, FontStyle.Bold),
DrawBorder = true,
Primary = buttonDef.Type == EButtonType.Toggle && buttonDef.IsToggled,
Visible = buttonDef.IsVisible
};
btn.Click += (_, _) =>
{
buttonDef.Execute();
if (buttonDef.Type == EButtonType.Toggle)
btn.Primary = buttonDef.IsToggled;
};
buttonDef.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
btn.Visible = buttonDef.IsVisible;
else if (args.PropertyName == nameof(ButtonDefinition.IsToggled))
btn.Primary = buttonDef.IsToggled;
};
// Insert before Minimize button
var minimizeIndex = flowLayoutPanel1.Controls.IndexOf(btnMinimize);
if (minimizeIndex >= 0)
flowLayoutPanel1.Controls.Add(btn);
flowLayoutPanel1.Controls.SetChildIndex(btn, minimizeIndex >= 0 ? minimizeIndex : flowLayoutPanel1.Controls.Count - 1);
_dynamicButtonControls[buttonDef.Key] = btn;
}
private void RemoveDynamicButton(ButtonDefinition buttonDef)
{
if (_dynamicButtonControls.TryGetValue(buttonDef.Key, out var ctrl))
{
flowLayoutPanel1.Controls.Remove(ctrl);
_dynamicButtonControls.Remove(buttonDef.Key);
}
}
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Specialized;
using System.Runtime.InteropServices;
using MaterialSkin;
using MaterialSkin.Controls;
@@ -6,6 +7,7 @@ using OpenCvSharp;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Services;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Windows.Test
{
@@ -15,6 +17,7 @@ namespace VisionBuilder.UI.Windows.Test
private readonly IPasswordInputService _passwordInputService;
private readonly UIConfiguration _uiConfiguration;
private readonly WindowsUISettings _windowsUiSettings;
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
public Form1(MainWindowVM mainWindowVm, IPasswordInputService passwordInputService, UIConfiguration uiConfiguration, WindowsUISettings windowsUiSettings)
{
@@ -32,6 +35,9 @@ namespace VisionBuilder.UI.Windows.Test
if(!_uiConfiguration.ShowTestModeButton)
flowLayoutPanel1.Controls.Remove(btnTestMode);
_mainWindowVm.DynamicButtons.CollectionChanged += DynamicButtons_CollectionChanged;
foreach (var btn in _mainWindowVm.DynamicButtons)
AddDynamicButton(btn);
}
private void _mainWindowVm_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -107,5 +113,69 @@ namespace VisionBuilder.UI.Windows.Test
{
return this;
}
private void DynamicButtons_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (ButtonDefinition btn in e.NewItems!)
AddDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Remove:
foreach (ButtonDefinition btn in e.OldItems!)
RemoveDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var ctrl in _dynamicButtonControls.Values)
flowLayoutPanel1.Controls.Remove(ctrl);
_dynamicButtonControls.Clear();
break;
}
}
private void AddDynamicButton(ButtonDefinition buttonDef)
{
var btn = new MaterialRaisedButton
{
Text = buttonDef.Title,
Size = new Size(176, 64),
Font = new Font("Segoe UI Semibold", 12F, FontStyle.Bold),
DrawBorder = true,
Primary = buttonDef.Type == ButtonType.Toggle && buttonDef.IsToggled,
Visible = buttonDef.IsVisible
};
btn.Click += (_, _) =>
{
buttonDef.Execute();
if (buttonDef.Type == ButtonType.Toggle)
btn.Primary = buttonDef.IsToggled;
};
buttonDef.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
btn.Visible = buttonDef.IsVisible;
else if (args.PropertyName == nameof(ButtonDefinition.IsToggled))
btn.Primary = buttonDef.IsToggled;
};
// Insert before Minimize button
var minimizeIndex = flowLayoutPanel1.Controls.IndexOf(btnMinimize);
if (minimizeIndex >= 0)
flowLayoutPanel1.Controls.Add(btn);
flowLayoutPanel1.Controls.SetChildIndex(btn, minimizeIndex >= 0 ? minimizeIndex : flowLayoutPanel1.Controls.Count - 1);
_dynamicButtonControls[buttonDef.Key] = btn;
}
private void RemoveDynamicButton(ButtonDefinition buttonDef)
{
if (_dynamicButtonControls.TryGetValue(buttonDef.Key, out var ctrl))
{
flowLayoutPanel1.Controls.Remove(ctrl);
_dynamicButtonControls.Remove(buttonDef.Key);
}
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Data;
using System.Drawing;
@@ -8,12 +9,14 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Windows.Components
{
public partial class SingleCameraControl : UserControl
{
private SingleCameraVM _singleCameraVm;
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
public void SetViewModel(SingleCameraVM singleCameraVm)
{
@@ -25,6 +28,10 @@ namespace VisionBuilder.UI.Windows.Components
SetBindings();
_singleCameraVm.PropertyChanged += SingleCameraVm_PropertyChanged;
_singleCameraVm.DynamicButtons.CollectionChanged += DynamicButtons_CollectionChanged;
foreach (var btn in _singleCameraVm.DynamicButtons)
AddDynamicButton(btn);
}
private void SingleCameraVm_PropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -53,6 +60,66 @@ namespace VisionBuilder.UI.Windows.Components
}
private void DynamicButtons_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (ButtonDefinition btn in e.NewItems!)
AddDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Remove:
foreach (ButtonDefinition btn in e.OldItems!)
RemoveDynamicButton(btn);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var ctrl in _dynamicButtonControls.Values)
flowLayoutPanel2.Controls.Remove(ctrl);
_dynamicButtonControls.Clear();
break;
}
}
private void AddDynamicButton(ButtonDefinition buttonDef)
{
var btn = new MaterialSkin.Controls.MaterialRaisedButton
{
Text = buttonDef.Title,
Size = new Size(201, 85),
Font = new Font("Segoe UI", 9F, FontStyle.Bold),
DrawBorder = true,
Primary = buttonDef.Type == EButtonType.Toggle && buttonDef.IsToggled,
Visible = buttonDef.IsVisible
};
btn.Click += (_, _) =>
{
buttonDef.Execute();
if (buttonDef.Type == EButtonType.Toggle)
btn.Primary = buttonDef.IsToggled;
};
buttonDef.PropertyChanged += (_, args) =>
{
if (args.PropertyName == nameof(ButtonDefinition.IsVisible))
btn.Visible = buttonDef.IsVisible;
else if (args.PropertyName == nameof(ButtonDefinition.IsToggled))
btn.Primary = buttonDef.IsToggled;
};
flowLayoutPanel2.Controls.Add(btn);
_dynamicButtonControls[buttonDef.Key] = btn;
}
private void RemoveDynamicButton(ButtonDefinition buttonDef)
{
if (_dynamicButtonControls.TryGetValue(buttonDef.Key, out var ctrl))
{
flowLayoutPanel2.Controls.Remove(ctrl);
_dynamicButtonControls.Remove(buttonDef.Key);
}
}
public SingleCameraControl()
{

View File

@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.3.11520.95 d18.3
VisualStudioVersion = 18.3.11520.95
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisionBuilder.UI.Common", "VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj", "{5AFF312A-EF5F-49ED-BF54-5AC69F7BEC03}"
EndProject
@@ -108,6 +108,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisionBuilder.UI.Blazor.Uno
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera", "Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera\Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera.csproj", "{FAD15887-EFB2-4FA5-8BD9-586C1DB2B0BE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisionBuilder.UI.RaspberryAcquisition", "VisionBuilder.UI.RaspberryAcquisition\VisionBuilder.UI.RaspberryAcquisition.csproj", "{149E2AFC-A714-4645-883A-2EE685E63FB9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisionBuilder.UI.Avalonia.Uno", "VisionBuilder.UI.Avalonia.Uno\VisionBuilder.UI.Avalonia.Uno.csproj", "{F70920C6-EF98-42B7-9F21-6E94781E9900}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LindtLeerformPlugin", "Plugins\LindtLeerformPlugin\LindtLeerformPlugin.csproj", "{5A1D9E37-353C-4E30-A302-48487DC3A9E8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -584,6 +590,42 @@ Global
{FAD15887-EFB2-4FA5-8BD9-586C1DB2B0BE}.Release|x64.Build.0 = Release|Any CPU
{FAD15887-EFB2-4FA5-8BD9-586C1DB2B0BE}.Release|x86.ActiveCfg = Release|Any CPU
{FAD15887-EFB2-4FA5-8BD9-586C1DB2B0BE}.Release|x86.Build.0 = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|x64.ActiveCfg = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|x64.Build.0 = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|x86.ActiveCfg = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Debug|x86.Build.0 = Debug|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|Any CPU.Build.0 = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|x64.ActiveCfg = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|x64.Build.0 = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|x86.ActiveCfg = Release|Any CPU
{149E2AFC-A714-4645-883A-2EE685E63FB9}.Release|x86.Build.0 = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|x64.ActiveCfg = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|x64.Build.0 = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|x86.ActiveCfg = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Debug|x86.Build.0 = Debug|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|Any CPU.Build.0 = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|x64.ActiveCfg = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|x64.Build.0 = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|x86.ActiveCfg = Release|Any CPU
{F70920C6-EF98-42B7-9F21-6E94781E9900}.Release|x86.Build.0 = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|x64.ActiveCfg = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|x64.Build.0 = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|x86.ActiveCfg = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Debug|x86.Build.0 = Debug|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|Any CPU.Build.0 = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|x64.ActiveCfg = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|x64.Build.0 = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|x86.ActiveCfg = Release|Any CPU
{5A1D9E37-353C-4E30-A302-48487DC3A9E8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -623,6 +665,9 @@ Global
{0C14CF80-D743-4809-9B9C-2A096FEA2E06} = {FA674B5A-3394-926C-2B1E-70E5B00E4A5C}
{9AE35B63-607E-4009-9FE6-3DF2CD98071F} = {F3414823-B70E-435E-B4EA-80ABF4371449}
{FAD15887-EFB2-4FA5-8BD9-586C1DB2B0BE} = {D05689E3-04C6-4E3B-ACA7-3F4507CED4CC}
{149E2AFC-A714-4645-883A-2EE685E63FB9} = {F3414823-B70E-435E-B4EA-80ABF4371449}
{F70920C6-EF98-42B7-9F21-6E94781E9900} = {F3414823-B70E-435E-B4EA-80ABF4371449}
{5A1D9E37-353C-4E30-A302-48487DC3A9E8} = {583A77DF-A293-4F3E-AB96-7310BC495822}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3CE42AE5-D79F-4E97-A246-AA8FD228B677}

1106
docs/PLUGIN_SYSTEM.md Normal file

File diff suppressed because it is too large Load Diff