Compare commits

..

10 Commits

Author SHA1 Message Date
EugeneTes
8704d0b5ac 2.0.15
profile influences configuration
2026-05-15 11:54:36 +02:00
EugeneTes
2be13501fa histogram implemented 2026-04-08 13:10:34 +02:00
EugeneTes
931de47164 cell histogram 2026-04-07 14:52:20 +02:00
EugeneTes
371436d0c3 re-render frame on active calibration 2026-04-07 10:10:58 +02:00
EugeneTes
bb861834fd settings for avalonia
libcamera bugfix
2026-04-01 14:05:37 +02:00
EugeneTes
1032a5a28c avalonia structure refactoring 2026-04-01 09:34:02 +02:00
EugeneTes
1c7a4abd5c leerform recipe subsystem created 2026-03-31 13:26:26 +02:00
EugeneTes
4bd117af47 plugin system docs
custom buttons for plugins
calibration functions for Leeform
2026-03-30 16:15:47 +02:00
EugeneTes
a1838cafeb opencv 4.10 2026-03-24 11:57:27 +01:00
EugeneTes
34b74ecdcd path fix 2026-03-23 10:30:30 +01:00
174 changed files with 9734 additions and 126 deletions

View File

@@ -3,7 +3,9 @@
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(dotnet sln:*)"
"Bash(dotnet sln:*)",
"Bash(dotnet build:*)",
"Bash(python3)"
]
}
}

View File

@@ -68,6 +68,11 @@ 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).
### Recognition Control
`IRecognitionControl` manages the per-camera image processing lifecycle (start/stop/pause, image loop, events). `BaseRecognitionControl` provides the threading, loop, and event infrastructure — subclasses implement `Initialize`, `WarmUp`, `ProcessImage`, and `GetRecipesData`. Each camera gets its own singleton instance via the child kernel. For full implementation guide, settings reference, and existing implementations, see [`docs/RECOGNITION_CONTROL.md`](docs/RECOGNITION_CONTROL.md).
### DI Container
Ninject `StandardKernel` is the root container. Child kernels (`Ninject.Extensions.ChildKernel`) scope per-camera services. All service resolution flows through the kernel — avoid `new` for services.
@@ -87,7 +92,7 @@ Ninject `StandardKernel` is the root container. Child kernels (`Ninject.Extensio
## Conventions
- Target platform is **x64** Windows.
- Target platform is **x64** Windows, but new code should be cross-platform — use Avalonia for UI, avoid WinForms-only or Windows-specific APIs.
- Culture is forced to `en-US` at startup.
- Nullable reference types are enabled across most projects.
- Operation attributes: `[Category("name")]` for UI grouping, `[NotForTool]` to exclude properties from serialization, `[IgnoreOperation]` to hide from discovery.

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Hawkeye.VisionBuilder.UI.RecipeYoloPatcher</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.6.0.20220608" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)'=='CPU'">
<PackageReference Include="YoloV8" Version="4.1.5" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.18.0" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)'!='CPU'">
<PackageReference Include="YoloV8.Gpu" Version="4.1.7" />
<PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu" Version="1.18.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hawkeye.VisionBuilder.Workflow\Hawkeye.VisionBuilder.Workflow.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,399 @@
#nullable disable
namespace Hawkeye.VisionBuilder.UI.RecipeYoloPatcher
{
partial class MainForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.lblModel = new System.Windows.Forms.Label();
this.txtModelPath = new System.Windows.Forms.TextBox();
this.btnBrowseModel = new System.Windows.Forms.Button();
this.btnLoadClasses = new System.Windows.Forms.Button();
this.lblClasses = new System.Windows.Forms.Label();
this.lstClasses = new System.Windows.Forms.CheckedListBox();
this.lblFiles = new System.Windows.Forms.Label();
this.lstFiles = new System.Windows.Forms.ListBox();
this.btnAddFiles = new System.Windows.Forms.Button();
this.btnRemoveFiles = new System.Windows.Forms.Button();
this.btnClearFiles = new System.Windows.Forms.Button();
this.grpBounds = new System.Windows.Forms.GroupBox();
this.lblMinArea = new System.Windows.Forms.Label();
this.numMinArea = new System.Windows.Forms.NumericUpDown();
this.lblMaxArea = new System.Windows.Forms.Label();
this.numMaxArea = new System.Windows.Forms.NumericUpDown();
this.lblMinWidth = new System.Windows.Forms.Label();
this.numMinWidth = new System.Windows.Forms.NumericUpDown();
this.lblMaxWidth = new System.Windows.Forms.Label();
this.numMaxWidth = new System.Windows.Forms.NumericUpDown();
this.lblMinHeight = new System.Windows.Forms.Label();
this.numMinHeight = new System.Windows.Forms.NumericUpDown();
this.lblMaxHeight = new System.Windows.Forms.Label();
this.numMaxHeight = new System.Windows.Forms.NumericUpDown();
this.btnApply = new System.Windows.Forms.Button();
this.btnStandardize = new System.Windows.Forms.Button();
this.txtLog = new System.Windows.Forms.TextBox();
this.grpBounds.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numMinArea)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxArea)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMinWidth)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxWidth)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMinHeight)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxHeight)).BeginInit();
this.SuspendLayout();
//
// lblModel
//
this.lblModel.AutoSize = true;
this.lblModel.Location = new System.Drawing.Point(10, 15);
this.lblModel.Name = "lblModel";
this.lblModel.Size = new System.Drawing.Size(46, 15);
this.lblModel.TabIndex = 0;
this.lblModel.Text = "Model:";
//
// txtModelPath
//
this.txtModelPath.Location = new System.Drawing.Point(70, 12);
this.txtModelPath.Name = "txtModelPath";
this.txtModelPath.ReadOnly = true;
this.txtModelPath.Size = new System.Drawing.Size(580, 23);
this.txtModelPath.TabIndex = 1;
//
// btnBrowseModel
//
this.btnBrowseModel.Location = new System.Drawing.Point(660, 10);
this.btnBrowseModel.Name = "btnBrowseModel";
this.btnBrowseModel.Size = new System.Drawing.Size(110, 25);
this.btnBrowseModel.TabIndex = 2;
this.btnBrowseModel.Text = "Browse...";
this.btnBrowseModel.UseVisualStyleBackColor = true;
this.btnBrowseModel.Click += new System.EventHandler(this.BtnBrowseModel_Click);
//
// btnLoadClasses
//
this.btnLoadClasses.Enabled = false;
this.btnLoadClasses.Location = new System.Drawing.Point(10, 45);
this.btnLoadClasses.Name = "btnLoadClasses";
this.btnLoadClasses.Size = new System.Drawing.Size(130, 25);
this.btnLoadClasses.TabIndex = 3;
this.btnLoadClasses.Text = "Load classes";
this.btnLoadClasses.UseVisualStyleBackColor = true;
this.btnLoadClasses.Click += new System.EventHandler(this.BtnLoadClasses_Click);
//
// lblClasses
//
this.lblClasses.AutoSize = true;
this.lblClasses.Location = new System.Drawing.Point(10, 85);
this.lblClasses.Name = "lblClasses";
this.lblClasses.Size = new System.Drawing.Size(228, 15);
this.lblClasses.TabIndex = 4;
this.lblClasses.Text = "Classes (check the ones to add/update):";
//
// lstClasses
//
this.lstClasses.CheckOnClick = true;
this.lstClasses.FormattingEnabled = true;
this.lstClasses.IntegralHeight = false;
this.lstClasses.Location = new System.Drawing.Point(10, 105);
this.lstClasses.Name = "lstClasses";
this.lstClasses.Size = new System.Drawing.Size(760, 180);
this.lstClasses.TabIndex = 5;
//
// lblFiles
//
this.lblFiles.AutoSize = true;
this.lblFiles.Location = new System.Drawing.Point(10, 295);
this.lblFiles.Name = "lblFiles";
this.lblFiles.Size = new System.Drawing.Size(173, 15);
this.lblFiles.TabIndex = 6;
this.lblFiles.Text = "Recipe files (*.hrcp, *.jhrcp):";
//
// lstFiles
//
this.lstFiles.FormattingEnabled = true;
this.lstFiles.HorizontalScrollbar = true;
this.lstFiles.IntegralHeight = false;
this.lstFiles.ItemHeight = 15;
this.lstFiles.Location = new System.Drawing.Point(10, 315);
this.lstFiles.Name = "lstFiles";
this.lstFiles.SelectionMode = System.Windows.Forms.SelectionMode.MultiExtended;
this.lstFiles.Size = new System.Drawing.Size(660, 130);
this.lstFiles.TabIndex = 7;
//
// btnAddFiles
//
this.btnAddFiles.Location = new System.Drawing.Point(680, 315);
this.btnAddFiles.Name = "btnAddFiles";
this.btnAddFiles.Size = new System.Drawing.Size(90, 25);
this.btnAddFiles.TabIndex = 8;
this.btnAddFiles.Text = "Add files...";
this.btnAddFiles.UseVisualStyleBackColor = true;
this.btnAddFiles.Click += new System.EventHandler(this.BtnAddFiles_Click);
//
// btnRemoveFiles
//
this.btnRemoveFiles.Location = new System.Drawing.Point(680, 345);
this.btnRemoveFiles.Name = "btnRemoveFiles";
this.btnRemoveFiles.Size = new System.Drawing.Size(90, 25);
this.btnRemoveFiles.TabIndex = 9;
this.btnRemoveFiles.Text = "Remove";
this.btnRemoveFiles.UseVisualStyleBackColor = true;
this.btnRemoveFiles.Click += new System.EventHandler(this.BtnRemoveFiles_Click);
//
// btnClearFiles
//
this.btnClearFiles.Location = new System.Drawing.Point(680, 375);
this.btnClearFiles.Name = "btnClearFiles";
this.btnClearFiles.Size = new System.Drawing.Size(90, 25);
this.btnClearFiles.TabIndex = 10;
this.btnClearFiles.Text = "Clear";
this.btnClearFiles.UseVisualStyleBackColor = true;
this.btnClearFiles.Click += new System.EventHandler(this.BtnClearFiles_Click);
//
// grpBounds
//
this.grpBounds.Controls.Add(this.lblMinArea);
this.grpBounds.Controls.Add(this.numMinArea);
this.grpBounds.Controls.Add(this.lblMaxArea);
this.grpBounds.Controls.Add(this.numMaxArea);
this.grpBounds.Controls.Add(this.lblMinWidth);
this.grpBounds.Controls.Add(this.numMinWidth);
this.grpBounds.Controls.Add(this.lblMaxWidth);
this.grpBounds.Controls.Add(this.numMaxWidth);
this.grpBounds.Controls.Add(this.lblMinHeight);
this.grpBounds.Controls.Add(this.numMinHeight);
this.grpBounds.Controls.Add(this.lblMaxHeight);
this.grpBounds.Controls.Add(this.numMaxHeight);
this.grpBounds.Location = new System.Drawing.Point(10, 455);
this.grpBounds.Name = "grpBounds";
this.grpBounds.Size = new System.Drawing.Size(760, 90);
this.grpBounds.TabIndex = 11;
this.grpBounds.TabStop = false;
this.grpBounds.Text = "Bounds (applied to selected classes)";
//
// lblMinArea
//
this.lblMinArea.AutoSize = true;
this.lblMinArea.Location = new System.Drawing.Point(10, 20);
this.lblMinArea.Name = "lblMinArea";
this.lblMinArea.Size = new System.Drawing.Size(56, 15);
this.lblMinArea.TabIndex = 0;
this.lblMinArea.Text = "Min Area";
//
// numMinArea
//
this.numMinArea.Location = new System.Drawing.Point(10, 40);
this.numMinArea.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMinArea.Name = "numMinArea";
this.numMinArea.Size = new System.Drawing.Size(110, 23);
this.numMinArea.TabIndex = 1;
//
// lblMaxArea
//
this.lblMaxArea.AutoSize = true;
this.lblMaxArea.Location = new System.Drawing.Point(130, 20);
this.lblMaxArea.Name = "lblMaxArea";
this.lblMaxArea.Size = new System.Drawing.Size(59, 15);
this.lblMaxArea.TabIndex = 2;
this.lblMaxArea.Text = "Max Area";
//
// numMaxArea
//
this.numMaxArea.Location = new System.Drawing.Point(130, 40);
this.numMaxArea.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMaxArea.Name = "numMaxArea";
this.numMaxArea.Size = new System.Drawing.Size(110, 23);
this.numMaxArea.TabIndex = 3;
this.numMaxArea.Value = new decimal(new int[] { 99999, 0, 0, 0 });
//
// lblMinWidth
//
this.lblMinWidth.AutoSize = true;
this.lblMinWidth.Location = new System.Drawing.Point(260, 20);
this.lblMinWidth.Name = "lblMinWidth";
this.lblMinWidth.Size = new System.Drawing.Size(64, 15);
this.lblMinWidth.TabIndex = 4;
this.lblMinWidth.Text = "Min Width";
//
// numMinWidth
//
this.numMinWidth.Location = new System.Drawing.Point(260, 40);
this.numMinWidth.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMinWidth.Name = "numMinWidth";
this.numMinWidth.Size = new System.Drawing.Size(110, 23);
this.numMinWidth.TabIndex = 5;
//
// lblMaxWidth
//
this.lblMaxWidth.AutoSize = true;
this.lblMaxWidth.Location = new System.Drawing.Point(390, 20);
this.lblMaxWidth.Name = "lblMaxWidth";
this.lblMaxWidth.Size = new System.Drawing.Size(67, 15);
this.lblMaxWidth.TabIndex = 6;
this.lblMaxWidth.Text = "Max Width";
//
// numMaxWidth
//
this.numMaxWidth.Location = new System.Drawing.Point(390, 40);
this.numMaxWidth.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMaxWidth.Name = "numMaxWidth";
this.numMaxWidth.Size = new System.Drawing.Size(110, 23);
this.numMaxWidth.TabIndex = 7;
this.numMaxWidth.Value = new decimal(new int[] { 99999, 0, 0, 0 });
//
// lblMinHeight
//
this.lblMinHeight.AutoSize = true;
this.lblMinHeight.Location = new System.Drawing.Point(520, 20);
this.lblMinHeight.Name = "lblMinHeight";
this.lblMinHeight.Size = new System.Drawing.Size(67, 15);
this.lblMinHeight.TabIndex = 8;
this.lblMinHeight.Text = "Min Height";
//
// numMinHeight
//
this.numMinHeight.Location = new System.Drawing.Point(520, 40);
this.numMinHeight.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMinHeight.Name = "numMinHeight";
this.numMinHeight.Size = new System.Drawing.Size(110, 23);
this.numMinHeight.TabIndex = 9;
//
// lblMaxHeight
//
this.lblMaxHeight.AutoSize = true;
this.lblMaxHeight.Location = new System.Drawing.Point(640, 20);
this.lblMaxHeight.Name = "lblMaxHeight";
this.lblMaxHeight.Size = new System.Drawing.Size(70, 15);
this.lblMaxHeight.TabIndex = 10;
this.lblMaxHeight.Text = "Max Height";
//
// numMaxHeight
//
this.numMaxHeight.Location = new System.Drawing.Point(640, 40);
this.numMaxHeight.Maximum = new decimal(new int[] { 9999999, 0, 0, 0 });
this.numMaxHeight.Name = "numMaxHeight";
this.numMaxHeight.Size = new System.Drawing.Size(110, 23);
this.numMaxHeight.TabIndex = 11;
this.numMaxHeight.Value = new decimal(new int[] { 99999, 0, 0, 0 });
//
// btnApply
//
this.btnApply.Location = new System.Drawing.Point(10, 555);
this.btnApply.Name = "btnApply";
this.btnApply.Size = new System.Drawing.Size(375, 32);
this.btnApply.TabIndex = 12;
this.btnApply.Text = "Apply";
this.btnApply.UseVisualStyleBackColor = true;
this.btnApply.Click += new System.EventHandler(this.BtnApply_Click);
//
// btnStandardize
//
this.btnStandardize.Location = new System.Drawing.Point(395, 555);
this.btnStandardize.Name = "btnStandardize";
this.btnStandardize.Size = new System.Drawing.Size(375, 32);
this.btnStandardize.TabIndex = 13;
this.btnStandardize.Text = "Standardize names";
this.btnStandardize.UseVisualStyleBackColor = true;
this.btnStandardize.Click += new System.EventHandler(this.BtnStandardize_Click);
//
// txtLog
//
this.txtLog.Location = new System.Drawing.Point(10, 595);
this.txtLog.Multiline = true;
this.txtLog.Name = "txtLog";
this.txtLog.ReadOnly = true;
this.txtLog.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
this.txtLog.Size = new System.Drawing.Size(760, 80);
this.txtLog.TabIndex = 14;
this.txtLog.WordWrap = false;
//
// MainForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(784, 690);
this.Controls.Add(this.txtLog);
this.Controls.Add(this.btnStandardize);
this.Controls.Add(this.btnApply);
this.Controls.Add(this.grpBounds);
this.Controls.Add(this.btnClearFiles);
this.Controls.Add(this.btnRemoveFiles);
this.Controls.Add(this.btnAddFiles);
this.Controls.Add(this.lstFiles);
this.Controls.Add(this.lblFiles);
this.Controls.Add(this.lstClasses);
this.Controls.Add(this.lblClasses);
this.Controls.Add(this.btnLoadClasses);
this.Controls.Add(this.btnBrowseModel);
this.Controls.Add(this.txtModelPath);
this.Controls.Add(this.lblModel);
this.Name = "MainForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Recipe Yolo Patcher";
this.grpBounds.ResumeLayout(false);
this.grpBounds.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.numMinArea)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxArea)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMinWidth)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxWidth)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMinHeight)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxHeight)).EndInit();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label lblModel;
private System.Windows.Forms.TextBox txtModelPath;
private System.Windows.Forms.Button btnBrowseModel;
private System.Windows.Forms.Button btnLoadClasses;
private System.Windows.Forms.Label lblClasses;
private System.Windows.Forms.CheckedListBox lstClasses;
private System.Windows.Forms.Label lblFiles;
private System.Windows.Forms.ListBox lstFiles;
private System.Windows.Forms.Button btnAddFiles;
private System.Windows.Forms.Button btnRemoveFiles;
private System.Windows.Forms.Button btnClearFiles;
private System.Windows.Forms.GroupBox grpBounds;
private System.Windows.Forms.Label lblMinArea;
private System.Windows.Forms.NumericUpDown numMinArea;
private System.Windows.Forms.Label lblMaxArea;
private System.Windows.Forms.NumericUpDown numMaxArea;
private System.Windows.Forms.Label lblMinWidth;
private System.Windows.Forms.NumericUpDown numMinWidth;
private System.Windows.Forms.Label lblMaxWidth;
private System.Windows.Forms.NumericUpDown numMaxWidth;
private System.Windows.Forms.Label lblMinHeight;
private System.Windows.Forms.NumericUpDown numMinHeight;
private System.Windows.Forms.Label lblMaxHeight;
private System.Windows.Forms.NumericUpDown numMaxHeight;
private System.Windows.Forms.Button btnApply;
private System.Windows.Forms.Button btnStandardize;
private System.Windows.Forms.TextBox txtLog;
}
}

View File

@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using Compunet.YoloV8;
using Hawkeye.VisionBuilder.Workflow;
using Hawkeye.VisionBuilder.Workflow.Operations.AI;
namespace Hawkeye.VisionBuilder.UI.RecipeYoloPatcher;
public partial class MainForm : Form
{
private sealed class ClassEntry
{
public int Id { get; init; }
public string Name { get; init; } = "";
public override string ToString() => $"{Id}: {Name}";
}
public MainForm()
{
InitializeComponent();
}
private void BtnBrowseModel_Click(object? sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Filter = "ONNX models (*.onnx)|*.onnx",
Title = "Select Yolo model"
};
var defaultDir = Path.GetFullPath(@"..\Data\Models");
if (Directory.Exists(defaultDir)) dialog.InitialDirectory = defaultDir;
if (dialog.ShowDialog(this) == DialogResult.OK)
{
txtModelPath.Text = dialog.FileName;
btnLoadClasses.Enabled = true;
lstClasses.Items.Clear();
}
}
private void BtnLoadClasses_Click(object? sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(txtModelPath.Text) || !File.Exists(txtModelPath.Text))
{
MessageBox.Show(this, "Pick a valid .onnx model first.", "Recipe Yolo Patcher",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
lstClasses.Items.Clear();
Cursor = Cursors.WaitCursor;
try
{
using var predictor = YoloV8Predictor.Create(txtModelPath.Text);
foreach (var cls in predictor.Metadata.Names)
{
lstClasses.Items.Add(new ClassEntry { Id = cls.Id, Name = cls.Name });
}
Log($"Loaded {lstClasses.Items.Count} class(es) from {Path.GetFileName(txtModelPath.Text)}.");
}
catch (Exception ex)
{
MessageBox.Show(this, $"Failed to load model:\r\n{ex.Message}", "Recipe Yolo Patcher",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
Cursor = Cursors.Default;
}
}
private void BtnAddFiles_Click(object? sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Filter = "Recipes (*.hrcp;*.jhrcp)|*.hrcp;*.jhrcp",
Multiselect = true,
Title = "Select recipe files"
};
var defaultDir = Path.GetFullPath(@"..\Data\Recipes");
if (Directory.Exists(defaultDir)) dialog.InitialDirectory = defaultDir;
if (dialog.ShowDialog(this) == DialogResult.OK)
{
foreach (var f in dialog.FileNames)
{
if (!lstFiles.Items.Contains(f)) lstFiles.Items.Add(f);
}
}
}
private void BtnRemoveFiles_Click(object? sender, EventArgs e)
{
var selected = lstFiles.SelectedItems.Cast<object>().ToList();
foreach (var item in selected) lstFiles.Items.Remove(item);
}
private void BtnClearFiles_Click(object? sender, EventArgs e) => lstFiles.Items.Clear();
private void BtnApply_Click(object? sender, EventArgs e)
{
var checkedClasses = lstClasses.CheckedItems.Cast<ClassEntry>().ToList();
if (checkedClasses.Count == 0)
{
MessageBox.Show(this, "Check at least one class to apply.", "Recipe Yolo Patcher",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var files = lstFiles.Items.Cast<string>().ToList();
if (files.Count == 0)
{
MessageBox.Show(this, "Add at least one recipe file.", "Recipe Yolo Patcher",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var bounds = new PatchBounds(
(int)numMinArea.Value, (int)numMaxArea.Value,
(int)numMinWidth.Value, (int)numMaxWidth.Value,
(int)numMinHeight.Value, (int)numMaxHeight.Value);
Cursor = Cursors.WaitCursor;
btnApply.Enabled = false;
try
{
foreach (var file in files)
{
try
{
var (added, updated) = PatchRecipe(file, checkedClasses, bounds);
Log($"OK: {Path.GetFileName(file)} — added {added}, updated {updated}");
}
catch (Exception ex)
{
Log($"FAIL: {Path.GetFileName(file)} — {ex.Message}");
}
}
Log("Done.");
}
finally
{
Cursor = Cursors.Default;
btnApply.Enabled = true;
}
}
private void BtnStandardize_Click(object? sender, EventArgs e)
{
var files = lstFiles.Items.Cast<string>().ToList();
if (files.Count == 0)
{
MessageBox.Show(this, "Add at least one recipe file.", "Recipe Yolo Patcher",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
Cursor = Cursors.WaitCursor;
btnApply.Enabled = false;
btnStandardize.Enabled = false;
try
{
foreach (var file in files)
{
try
{
var renamed = StandardizeLabels(file);
Log($"OK: {Path.GetFileName(file)} — renamed {renamed} label(s)");
}
catch (Exception ex)
{
Log($"FAIL: {Path.GetFileName(file)} — {ex.Message}");
}
}
Log("Done.");
}
finally
{
Cursor = Cursors.Default;
btnApply.Enabled = true;
btnStandardize.Enabled = true;
}
}
private static int StandardizeLabels(string path)
{
var (wf, ext) = LoadRecipe(path);
int renamed = 0;
foreach (var op in wf.Operations.OfType<YoloPickDetected>())
{
var newLabel = ToPascalCase(op.Label);
if (newLabel != op.Label)
{
op.Label = newLabel;
renamed++;
}
}
if (renamed > 0) SaveRecipe(wf, path, ext);
return renamed;
}
private static string ToPascalCase(string? label)
{
if (string.IsNullOrWhiteSpace(label)) return label ?? string.Empty;
var parts = label.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var sb = new StringBuilder(label.Length);
foreach (var p in parts)
{
sb.Append(char.ToUpperInvariant(p[0]));
if (p.Length > 1) sb.Append(p, 1, p.Length - 1);
}
return sb.ToString();
}
private static (WorkflowList wf, string ext) LoadRecipe(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
WorkflowList wf;
if (ext == ".hrcp")
{
wf = new WorkflowList();
using var fs = File.OpenRead(path);
using var br = new BinaryReader(fs);
wf.Load(br, new OperationDiscoveryService(wf));
}
else if (ext == ".jhrcp")
{
wf = WorkflowList.LoadJSONFromFile(path);
}
else
{
throw new InvalidOperationException($"Unsupported extension '{ext}'.");
}
return (wf, ext);
}
private static void SaveRecipe(WorkflowList wf, string path, string ext)
{
if (ext == ".hrcp")
{
using var fs = File.Create(path);
using var bw = new BinaryWriter(fs);
wf.Save(bw);
}
else
{
wf.SaveJSON(path);
}
}
private readonly record struct PatchBounds(int MinArea, int MaxArea, int MinWidth, int MaxWidth, int MinHeight, int MaxHeight);
private static (int added, int updated) PatchRecipe(string path, IReadOnlyList<ClassEntry> classes, PatchBounds b)
{
var (wf, ext) = LoadRecipe(path);
int anchor = -1;
for (int i = 0; i < wf.Operations.Count; i++)
{
if (wf.Operations[i] is YoloPickDetected) anchor = i;
}
if (anchor < 0) anchor = wf.Operations.Count - 1;
var nameMap = wf.Operations.OfType<YoloPickDetected>()
.Where(op => !string.IsNullOrEmpty(op.Label))
.GroupBy(op => op.Label)
.ToDictionary(g => g.Key, g => g.First());
int added = 0, updated = 0;
foreach (var cls in classes)
{
if (nameMap.TryGetValue(cls.Name, out var existing))
{
existing.MinArea = b.MinArea;
existing.MaxArea = b.MaxArea;
existing.MinWidth = b.MinWidth;
existing.MaxWidth = b.MaxWidth;
existing.MinHeight = b.MinHeight;
existing.MaxHeight = b.MaxHeight;
updated++;
}
else
{
var op = new YoloPickDetected
{
Label = cls.Name,
TypeId = cls.Id + 1,
SlotName = "Image",
MinArea = b.MinArea,
MaxArea = b.MaxArea,
MinWidth = b.MinWidth,
MaxWidth = b.MaxWidth,
MinHeight = b.MinHeight,
MaxHeight = b.MaxHeight
};
wf.Operations.Insert(++anchor, op);
nameMap[cls.Name] = op;
added++;
}
}
SaveRecipe(wf, path, ext);
return (added, updated);
}
private void Log(string msg)
{
txtLog.AppendText(msg + Environment.NewLine);
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,14 @@
using System;
using System.Windows.Forms;
namespace Hawkeye.VisionBuilder.UI.RecipeYoloPatcher;
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}

View File

@@ -80,7 +80,7 @@ namespace Hawkeye.VisionBuilder.UI.Sources.Emulation
try
{
_currentFolder = imagesFolder;
var path = Path.Combine(@"..\Data\Emulation", imagesFolder);
var path = Path.Combine("..", "Data", "Emulation", imagesFolder);
_selectedFiles = Directory.GetFiles(path, "*.bmp")
.Concat(Directory.GetFiles(path, "*.png"))
.Concat(Directory.GetFiles(path, "*.jpg"))

View File

@@ -105,11 +105,13 @@ namespace Hawkeye.VisionBuilder.UI.Sources.Hawkeye
var pointer = pinnedArray.AddrOfPinnedObject();
Mat image = new Mat(_cameraSettings.ImageSettings.Lines, _cameraSettings.ImageSettings.SensorWidth,
Mat image = Mat.FromPixelData(_cameraSettings.ImageSettings.Lines, _cameraSettings.ImageSettings.SensorWidth,
MatType.CV_8UC1, pointer);
var xCrop = _cameraSettings.ImageSettings.SensorWidth - _cameraSettings.ImageWidth -
_cameraSettings.OffsetX;

View File

@@ -100,9 +100,9 @@ public class OpenCVBayerProcessor
Mat greenCorrected = new Mat();
Mat redCorrected = new Mat();
Mat blueLutMat = new Mat(1, 256, MatType.CV_8U, blueLut);
Mat greenLutMat = new Mat(1, 256, MatType.CV_8U, greenLut);
Mat redLutMat = new Mat(1, 256, MatType.CV_8U, redLut);
Mat blueLutMat = Mat.FromPixelData(1, 256, MatType.CV_8U, blueLut);
Mat greenLutMat = Mat.FromPixelData(1, 256, MatType.CV_8U, greenLut);
Mat redLutMat = Mat.FromPixelData(1, 256, MatType.CV_8U, redLut);
Cv2.LUT(channels[0], blueLutMat, blueCorrected);
Cv2.LUT(channels[1], greenLutMat, greenCorrected);

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera;
public class LibcameraCaptureSettings : ISettings
{
public string CameraName { get; }
public LibcameraCaptureSettings(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

@@ -0,0 +1,154 @@
using OpenCvSharp;
using System.Diagnostics;
using System.Threading.Channels;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Processing;
namespace Hawkeye.VisionBuilder.UI.Sources.Raspberry.Libcamera
{
public class LibcameraImageSource : IImageSource, IVisionBuilderModule
{
private LibcameraCaptureSettings _settings;
public LibcameraImageSource(LibcameraCaptureSettings settings)
{
_settings = settings;
}
public async Task<Mat> GetImage(CancellationToken token)
{
return await _imageChannel.Reader.ReadAsync(token);
}
public void InitializeModule()
{
Start();
}
byte[] JpegHeader = new byte[] { 0xff, 0xd8 };
byte[] JpegFooter = new byte[] { 0xff, 0xd9 };
int ChunkSize = 1024;
Channel<Mat> _imageChannel = Channel.CreateBounded<Mat>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
public void Start()
{
Thread th = new Thread(StartLoop);
th.IsBackground = true;
th.Start();
}
private void StartLoop()
{
try
{
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))
{
byte[] imageBuffer = new byte[1024 * 1024];
// decode MJPEG buffer
var buff = br.ReadBytes(ChunkSize);
int frameId = 0;
while (true)
{
var imageStart = Find(buff, JpegHeader);
if (imageStart != -1)
{
var size = buff.Length - imageStart;
Array.Copy(buff, imageStart, imageBuffer, 0, size);
while (true)
{
buff = br.ReadBytes(ChunkSize);
var imageEnd = Find(buff, JpegFooter);
if (imageEnd != -1)
{
var frame = new byte[size + imageEnd];
Array.Copy(imageBuffer, frame, size);
Array.Copy(buff, 0, frame, size, imageEnd);
// process frame
frameId++;
var decoded = Mat.ImDecode(frame);
_imageChannel.Writer.WriteAsync(decoded, CancellationToken.None);
// copy the leftover data to the start
Array.Copy(buff, imageEnd, buff, 0, buff.Length - imageEnd);
// fill the remainder of the buffer with new data and start over
var temp = br.ReadBytes(imageEnd);
Array.Copy(temp, 0, buff, buff.Length - imageEnd, temp.Length);
break;
}
// copy all of the data to the imageBuffer
Array.Copy(buff, 0, imageBuffer, size, buff.Length);
size += buff.Length;
}
}
else
{
Console.WriteLine("JPEG header not found.");
break;
}
}
process.Kill();
}
}
catch (Exception e)
{
Serilog.Log.Error($"Error starting libcamera: {e}");
}
}
public static int Find(byte[] buff, byte[] search)
{
// enumerate the buffer but don't overstep the bounds
for (int start = 0; start < buff.Length - search.Length; start++)
{
// we found the first character
if (buff[start] == search[0])
{
int next;
// traverse the rest of the bytes
for (next = 1; next < search.Length; next++)
{
// if we don't match, bail
if (buff[start + next] != search[next])
break;
}
if (next == search.Length)
return start;
}
}
// not found
return -1;
}
public async Task<Mat> GetImageAsync(CancellationToken cancellationToken)
{
return await _imageChannel.Reader.ReadAsync(cancellationToken);
}
}
}

View File

@@ -5,7 +5,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<PlatformTarget>x64</PlatformTarget>
<Configurations>Debug;Release;CPU</Configurations>
</PropertyGroup>
@@ -32,8 +31,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenCvSharp4" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.10.0.20241108" />
</ItemGroup>

View File

@@ -93,7 +93,8 @@ public class AnomalyAI: BaseOperation
// black and white mask
var mask = new Mat(30, 48, MatType.CV_8UC1, resultColor);
var mask = new Mat(30, 48, MatType.CV_8UC1);
mask.SetArray(resultColor);
var maskResized = mask.Resize(new Size(currentImage.ImageData.Width, currentImage.ImageData.Height));

View File

@@ -60,7 +60,8 @@ public class BackgroundSeparationModelOperation:BaseOperation,IHaveOrigin
var bytes= MatToBytes(img);
var responseBytes = _transfer.TransferData(ModelName, 3, bytes);
var response = new Mat(img.Rows, img.Cols, MatType.CV_8UC1, responseBytes);
var response = new Mat(img.Rows, img.Cols, MatType.CV_8UC1);
response.SetArray(responseBytes);
var contours = response.Threshold(128, 255, ThresholdTypes.Binary).FindContoursAsArray(RetrievalModes.List, ContourApproximationModes.ApproxSimple);

View File

@@ -78,8 +78,9 @@ public class Color128HalfOperation:BaseOperation
.ToArray();
// black and white mask
var mask = new Mat(_imageHeight, _imageWidth, MatType.CV_8UC1, resultColor);
var mask = new Mat(_imageHeight, _imageWidth, MatType.CV_8UC1);
mask.SetArray(resultColor);
var maskResized = mask.Resize(new Size(currentImage.ImageData.Width, currentImage.ImageData.Height));
context.ActiveImage = new HawkeyeImage() {ImageData = maskResized};

View File

@@ -111,8 +111,9 @@ public abstract class ColorAIOperation:BaseOperation
// black and white mask
var mask = new Mat(_imageHeight, _imageWidth, MatType.CV_8UC1, resultColor);
var mask = new Mat(_imageHeight, _imageWidth, MatType.CV_8UC1);
mask.SetArray(resultColor);
var maskResized = mask.Resize(new Size(currentImage.ImageData.Width, currentImage.ImageData.Height));
context.ActiveImage = new HawkeyeImage() { ImageData = maskResized };

View File

@@ -129,7 +129,8 @@ public class ModelAIOperation: BaseOperation
// black and white mask
var mask = new Mat(outputSizeArray[0], outputSizeArray[1], MatType.CV_8UC1, resultColor);
var mask = new Mat(outputSizeArray[0], outputSizeArray[1], MatType.CV_8UC1);
mask.SetArray(resultColor);
var maskResized = mask.Resize(new Size(currentImage.ImageData.Width, currentImage.ImageData.Height));

View File

@@ -105,7 +105,8 @@ public class RawModelAIOperation: BaseOperation
var outputSizeArray = outputSize.Select(Convert.ToInt32).ToArray();
var mask = new Mat(outputSizeArray[0], outputSizeArray[1], MatType.CV_8UC1, resultColor);
var mask = new Mat(outputSizeArray[0], outputSizeArray[1], MatType.CV_8UC1);
mask.SetArray(resultColor);
var maskResized = mask.Resize(new Size(currentImage.ImageData.Width, currentImage.ImageData.Height));

View File

@@ -13,7 +13,7 @@ public class SkeletonOperation:BaseOperation
if(!CheckGrayscale(context)) return;
var skeleton = new Mat(context.ActiveImage.ImageData.Size(), MatType.CV_8UC1, 0);
var skeleton = new Mat(context.ActiveImage.ImageData.Size(), MatType.CV_8UC1, Scalar.All(0));
var temp = context.ActiveImage.ImageData.Clone();
var element = Cv2.GetStructuringElement(MorphShapes.Cross, new OpenCvSharp.Size(3, 3));
var image = context.ActiveImage.ImageData.Clone();

View File

@@ -49,13 +49,13 @@ public class GaborFilterOperation : BaseOperation
}
var image = context.ActiveImage.ImageData;
var newimage = new Mat(image.Size(), image.Type(), 0);
var newimage = new Mat(image.Size(), image.Type(), Scalar.All(0));
foreach (double theta in steps)
{
var kernel = Cv2.GetGaborKernel(new Size(KSize, KSize), Sigma, theta, Lambda, Gamma, 0, MatType.CV_64F);
kernel /= 1 * kernel.Sum().Val0;
var filtered = new Mat(image.Size(), image.Type(), 0);
var filtered = new Mat(image.Size(), image.Type(), Scalar.All(0));
Cv2.Filter2D(image, filtered, -1, kernel);
Cv2.Max(newimage, filtered, newimage);
}

View File

@@ -21,7 +21,7 @@ public class ImageSizeProportionOperation:BaseOperation
context.ActiveImage = new HawkeyeImage()
{
ImageData = context.ActiveImage!.ImageData.Resize(Size.Zero, Width, Height)
ImageData = context.ActiveImage!.ImageData.Resize(new Size(0,0), Width, Height)
};
Status = $"Image resized to {context.ActiveImage.ImageData.Width}x{context.ActiveImage.ImageData.Height}";

View File

@@ -18,7 +18,7 @@ public class InvertOperation:BaseOperation
if (!CheckImageExists(context)) return;
var res = new Mat(context.ActiveImage.ImageData.Size(), context.ActiveImage.ImageData.Type(), 0);
var res = new Mat(context.ActiveImage.ImageData.Size(), context.ActiveImage.ImageData.Type(), Scalar.All(0));
Cv2.BitwiseNot(context.ActiveImage.ImageData, res);
context.ActiveImage = new HawkeyeImage()
{

View File

@@ -31,7 +31,7 @@ public class TakeBiggestOperation : BaseOperation
var largestContour = contours.OrderByDescending(c => Cv2.ContourArea(c)).First();
var mask = Mat.Zeros(image.Size(), MatType.CV_8UC1).ToMat();
Cv2.DrawContours(mask, new[] {largestContour}, -1, 255, -1);
Cv2.DrawContours(mask, new[] {largestContour}, -1, Scalar.All(255), -1);
context.ActiveImage = new HawkeyeImage()
{

View File

@@ -17,7 +17,7 @@ public class DistanceTransformOperation:BaseOperation
Cv2.MinMaxLoc(distance, out double minVal, out var maxVal);
// clamp the distance transform to 0-255
Cv2.Normalize(distance, res, 0, maxVal, NormTypes.MinMax, MatType.CV_8UC1);
Cv2.Normalize(distance, res, 0, maxVal, NormTypes.MinMax, (int)MatType.CV_8UC1);

View File

@@ -16,9 +16,9 @@
<ItemGroup>
<PackageReference Include="AutoCompleteMenu-ScintillaNET" Version="2.1.0" />
<PackageReference Include="MathNet.Numerics" Version="5.0.0" />
<PackageReference Include="OpenCvSharp4" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.10.0.20241108" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
<PackageReference Include="Scintilla.NET" Version="5.3.2.7" />
<PackageReference Include="WindowsAPICodePack" Version="8.0.6" />
</ItemGroup>

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<EnableDynamicLoading>true</EnableDynamicLoading>
<Nullable>enable</Nullable>
<OutDir>..\..\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\CandyboxPlugin</OutDir>
<OutDir>..\..\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\CandyboxPlugin</OutDir>
</PropertyGroup>

View File

@@ -19,7 +19,7 @@ namespace CandyboxPlugin.Detectors
// get this dll path
var path=Assembly.GetCallingAssembly().Location;
var dir=System.IO.Path.GetDirectoryName(path);
var modelPath = Path.Combine(dir,"models\\" + MODEL_NAME);
var modelPath = Path.Combine(dir,"models" , MODEL_NAME);
_AIsession = new InferenceSession(modelPath, opts);
_inputName = _AIsession.InputMetadata.First().Key;
_dimensions = _AIsession.InputMetadata.First().Value.Dimensions;

View File

@@ -18,7 +18,7 @@ namespace CandyboxPlugin.Detectors
// get this dll path
var path = Assembly.GetCallingAssembly().Location;
var dir = System.IO.Path.GetDirectoryName(path);
var modelPath = Path.Combine(dir, "models\\" + MODEL_NAME);
var modelPath = Path.Combine(dir, "models" , MODEL_NAME);
_AIsession = new InferenceSession(modelPath, opts);
_inputName = _AIsession.InputMetadata.First().Key;
_dimensions = _AIsession.InputMetadata.First().Value.Dimensions;

View File

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

View File

@@ -0,0 +1,71 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using LindtLeerformPlugin.Services;
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;
private readonly LeerformPatternRecognitionService _patternService;
private readonly LeerformRecipeStore _recipeStore;
public LeerformModule(
SingleCameraVM singleCameraVm,
LeerformSettings settings,
IImageSource imageSource,
LeerformPatternRecognitionService patternService,
LeerformRecipeStore recipeStore)
{
_singleCameraVm = singleCameraVm;
_settings = settings;
_imageSource = imageSource;
_patternService = patternService;
_recipeStore = recipeStore;
}
public void InitializeModule()
{
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, _singleCameraVm, _patternService, _recipeStore);
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,26 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using LindtLeerformPlugin.Views;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace LindtLeerformPlugin;
public class LeerformRecipeCreationTool : IRecipeCreationTool
{
public bool Enabled { get; set; } = true;
public async Task<string?> CreateRecipeAsync()
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return null;
var parent = desktop.MainWindow;
if (parent == null)
return null;
var dialog = new RecipeNameDialog();
var result = await dialog.ShowDialog<string?>(parent);
return string.IsNullOrWhiteSpace(result) ? null : result;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace LindtLeerformPlugin;
public class LeerformSettings : ISettings
{
public string CameraName { get; }
public LeerformSettings(string cameraName)
{
CameraName = cameraName;
}
public int CheckerboardRows { get; set; } = 6;
public int CheckerboardCols { get; set; } = 9;
public string CalibrationImageDirectory { get; set; } = Path.Combine("..", "Data", "CalibrationImages");
public void RegisterSettings(InspectronSettings settings)
{
settings.RegisterSimple(this, () => CheckerboardRows, $"{CameraName}/Leerform", nameof(CheckerboardRows));
settings.RegisterSimple(this, () => CheckerboardCols, $"{CameraName}/Leerform", nameof(CheckerboardCols));
settings.RegisterSimple(this, () => CalibrationImageDirectory, $"{CameraName}/Leerform", nameof(CalibrationImageDirectory));
}
}

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,13 @@
namespace LindtLeerformPlugin.Models;
public class CellPattern
{
public int Rows { get; set; } = 4;
public int Cols { get; set; } = 5;
public CellShape Shape { get; set; } = CellShape.Round;
public int Padding { get; set; } = 5;
public int RoiX { get; set; }
public int RoiY { get; set; }
public int RoiWidth { get; set; }
public int RoiHeight { get; set; }
}

View File

@@ -0,0 +1,13 @@
using OpenCvSharp;
namespace LindtLeerformPlugin.Models;
public class CellRegion
{
public int Index { get; set; }
public int Row { get; set; }
public int Col { get; set; }
public Rect BoundingBox { get; set; }
public Point Center { get; set; }
public int Radius { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace LindtLeerformPlugin.Models;
public class CellResult
{
public int Index { get; set; }
/// <summary>True when no chocolate blobs were detected inside this cell (the form is empty as expected).</summary>
public bool IsGood { get; set; }
/// <summary>Number of contours inside the cell that passed the area filter.</summary>
public int BlobCount { get; set; }
/// <summary>Total pixel area classified as chocolate inside this cell (sum of accepted contour areas).</summary>
public int DetectedArea { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace LindtLeerformPlugin.Models;
public enum CellShape
{
Square,
Round
}

View File

@@ -0,0 +1,21 @@
namespace LindtLeerformPlugin.Models;
public class LeerformRecipe
{
public string RecipeName { get; set; } = string.Empty;
public CellPattern Pattern { get; set; } = new();
/// <summary>LAB a* threshold for white chocolate (THRESH_BINARY_INV). Pixels with a* below this become foreground.</summary>
public int AThreshold { get; set; } = 140;
/// <summary>LAB L* threshold for dark chocolate (THRESH_BINARY_INV). Pixels with L* below this become foreground.</summary>
public int LThreshold { get; set; } = 100;
/// <summary>Minimum contour area (px) for a detected blob to count.</summary>
public int MinBlobArea { get; set; } = 100;
/// <summary>Maximum contour area (px) for a detected blob to count.</summary>
public int MaxBlobArea { get; set; } = 5000;
public string ThumbnailBase64 { get; set; } = string.Empty;
}

View File

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

View File

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

View File

@@ -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,218 @@
using LindtLeerformPlugin.Models;
using OpenCvSharp;
namespace LindtLeerformPlugin.Services;
public class LeerformPatternRecognitionService
{
private static readonly Scalar GoodColor = new(0, 255, 0); // BGR Green
private static readonly Scalar BadColor = new(0, 0, 255); // BGR Red
public IReadOnlyList<CellRegion> ComputeCells(CellPattern pattern)
{
var cells = new List<CellRegion>();
if (pattern.Rows <= 0 || pattern.Cols <= 0 || pattern.RoiWidth <= 0 || pattern.RoiHeight <= 0)
return cells;
var cellW = pattern.RoiWidth / pattern.Cols;
var cellH = pattern.RoiHeight / pattern.Rows;
var padding = Math.Max(0, pattern.Padding);
var index = 0;
for (var r = 0; r < pattern.Rows; r++)
{
for (var c = 0; c < pattern.Cols; c++)
{
var x = pattern.RoiX + c * cellW + padding;
var y = pattern.RoiY + r * cellH + padding;
var w = Math.Max(1, cellW - 2 * padding);
var h = Math.Max(1, cellH - 2 * padding);
var rect = new Rect(x, y, w, h);
var center = new Point(x + w / 2, y + h / 2);
var radius = Math.Max(1, Math.Min(w, h) / 2);
cells.Add(new CellRegion
{
Index = index++,
Row = r,
Col = c,
BoundingBox = rect,
Center = center,
Radius = radius
});
}
}
return cells;
}
public Mat BuildMask(CellPattern pattern, Size imageSize, int? cellIndex = null)
{
var mask = new Mat(imageSize, MatType.CV_8UC1, Scalar.All(0));
var cells = ComputeCells(pattern);
var color = Scalar.All(255);
var imageRect = new Rect(0, 0, imageSize.Width, imageSize.Height);
foreach (var cell in cells)
{
if (cellIndex.HasValue && cellIndex.Value != cell.Index)
continue;
var clipped = cell.BoundingBox & imageRect;
if (clipped.Width <= 0 || clipped.Height <= 0)
continue;
if (pattern.Shape == CellShape.Square)
{
Cv2.Rectangle(mask, clipped, color, thickness: -1);
}
else
{
Cv2.Circle(mask, cell.Center, cell.Radius, color, thickness: -1);
}
}
return mask;
}
/// <summary>
/// Build a single foreground mask covering both white and dark chocolate flecks on the pink mold,
/// using the LAB-channel thresholding approach from <c>jupiter/grid_test.ipynb</c>.
/// <list type="bullet">
/// <item><description><b>White chocolate</b> — pink mold has a* well above 128 (red), white sits near 128. Inverse-threshold a* so neutral pixels become foreground.</description></item>
/// <item><description><b>Dark chocolate</b> — pink mold is bright (high L*), dark chocolate is dark. Inverse-threshold L* so dark pixels become foreground.</description></item>
/// </list>
/// The two masks are OR-merged, then morphologically opened (3Ă—3 ELLIPSE) and closed (5Ă—5 ELLIPSE)
/// to drop single-pixel noise and merge speck fragments. Caller owns the returned <see cref="Mat"/>.
/// </summary>
public static Mat DetectChocolateMask(Mat bgrImage, int aThreshold, int lThreshold)
{
using var lab = new Mat();
Cv2.CvtColor(bgrImage, lab, ColorConversionCodes.BGR2Lab);
var channels = Cv2.Split(lab);
try
{
var lChan = channels[0];
var aChan = channels[1];
using var whiteMask = new Mat();
Cv2.Threshold(aChan, whiteMask, aThreshold, 255, ThresholdTypes.BinaryInv);
using var darkMask = new Mat();
Cv2.Threshold(lChan, darkMask, lThreshold, 255, ThresholdTypes.BinaryInv);
var combined = new Mat();
Cv2.BitwiseOr(whiteMask, darkMask, combined);
using var openKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3));
using var closeKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(5, 5));
Cv2.MorphologyEx(combined, combined, MorphTypes.Open, openKernel);
Cv2.MorphologyEx(combined, combined, MorphTypes.Close, closeKernel);
return combined;
}
finally
{
foreach (var ch in channels)
ch.Dispose();
}
}
public IReadOnlyList<CellResult> EvaluateCells(Mat bgrImage, LeerformRecipe recipe)
{
using var chocolateMask = DetectChocolateMask(bgrImage, recipe.AThreshold, recipe.LThreshold);
var cells = ComputeCells(recipe.Pattern);
var imageSize = new Size(bgrImage.Cols, bgrImage.Rows);
var results = new List<CellResult>(cells.Count);
foreach (var cell in cells)
{
using var cellMask = BuildMask(recipe.Pattern, imageSize, cell.Index);
using var perCell = new Mat();
Cv2.BitwiseAnd(chocolateMask, cellMask, perCell);
Cv2.FindContours(
perCell,
out var contours,
out _,
RetrievalModes.External,
ContourApproximationModes.ApproxSimple);
var blobs = 0;
var area = 0;
foreach (var contour in contours)
{
var contourArea = (int)Cv2.ContourArea(contour);
if (contourArea >= recipe.MinBlobArea && contourArea <= recipe.MaxBlobArea)
{
blobs++;
area += contourArea;
}
}
results.Add(new CellResult
{
Index = cell.Index,
IsGood = blobs == 0,
BlobCount = blobs,
DetectedArea = area
});
}
return results;
}
public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList<CellResult> results)
{
var overlay = bgrImage.Clone();
var cells = ComputeCells(pattern);
var resultByIndex = results.ToDictionary(r => r.Index);
const int thickness = 2;
foreach (var cell in cells)
{
if (!resultByIndex.TryGetValue(cell.Index, out var result))
continue;
var color = result.IsGood ? GoodColor : BadColor;
if (pattern.Shape == CellShape.Square)
Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness);
else
Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness);
}
return overlay;
}
public void DrawPattern(Mat target, CellPattern pattern, Scalar color, int thickness = 2)
{
var cells = ComputeCells(pattern);
foreach (var cell in cells)
{
if (pattern.Shape == CellShape.Square)
Cv2.Rectangle(target, cell.BoundingBox, color, thickness);
else
Cv2.Circle(target, cell.Center, cell.Radius, color, thickness);
}
}
public int? FindCellAtPoint(int x, int y, CellPattern pattern)
{
var cells = ComputeCells(pattern);
foreach (var cell in cells)
{
if (pattern.Shape == CellShape.Square)
{
if (cell.BoundingBox.Contains(x, y))
return cell.Index;
}
else
{
var dx = x - cell.Center.X;
var dy = y - cell.Center.Y;
if (dx * dx + dy * dy <= cell.Radius * cell.Radius)
return cell.Index;
}
}
return null;
}
}

View File

@@ -0,0 +1,77 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LindtLeerformPlugin.Models;
using OpenCvSharp;
namespace LindtLeerformPlugin.Services;
public class LeerformRecipeStore
{
private const string RecipeExtension = ".jleerform";
public string RecipesDirectory { get; } = Path.Combine("..", "Data", "Recipes");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public IReadOnlyList<string> ListRecipeNames()
{
if (!Directory.Exists(RecipesDirectory))
return Array.Empty<string>();
return Directory.GetFiles(RecipesDirectory, "*" + RecipeExtension)
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !string.IsNullOrEmpty(name))
.Select(name => name!)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public LeerformRecipe? Load(string recipeName)
{
if (string.IsNullOrWhiteSpace(recipeName))
return null;
var path = GetRecipePath(recipeName);
if (!File.Exists(path))
return null;
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<LeerformRecipe>(json, JsonOptions);
}
public void Save(LeerformRecipe recipe)
{
if (string.IsNullOrWhiteSpace(recipe.RecipeName))
throw new ArgumentException("Recipe must have a name", nameof(recipe));
Directory.CreateDirectory(RecipesDirectory);
var path = GetRecipePath(recipe.RecipeName);
var json = JsonSerializer.Serialize(recipe, JsonOptions);
File.WriteAllText(path, json);
}
public static Mat? DecodeThumbnail(LeerformRecipe recipe)
{
if (string.IsNullOrEmpty(recipe.ThumbnailBase64))
return null;
try
{
var bytes = Convert.FromBase64String(recipe.ThumbnailBase64);
var mat = Cv2.ImDecode(bytes, ImreadModes.Color);
return mat.Empty() ? null : mat;
}
catch
{
return null;
}
}
private string GetRecipePath(string recipeName) =>
Path.Combine(RecipesDirectory, recipeName + RecipeExtension);
}

View File

@@ -0,0 +1,437 @@
using System.ComponentModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LindtLeerformPlugin.Models;
using LindtLeerformPlugin.Services;
using OpenCvSharp;
using Serilog;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Processing;
namespace LindtLeerformPlugin.ViewModels;
public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
{
private readonly IImageSource _imageSource;
private readonly LeerformSettings _settings;
private readonly SingleCameraVM _singleCameraVm;
private readonly LeerformPatternRecognitionService _patternService;
private readonly LeerformRecipeStore _recipeStore;
private CancellationTokenSource? _previewCts;
private Mat? _lastFrame;
private Mat? _calibCameraMatrix;
private Mat? _calibDistCoeffs;
private Mat? _referenceFrame;
[ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage;
[ObservableProperty] private string _statusText = "Ready";
[ObservableProperty] private int _capturedImageCount;
[ObservableProperty] private bool _isPreviewRunning;
[ObservableProperty] private bool _isCalibrated;
[ObservableProperty] private bool _applyCalibration;
[ObservableProperty] private string _calibrationImageDirectory;
[ObservableProperty] private double _rmsError;
// Pattern
[ObservableProperty] private decimal? _rows = 4m;
[ObservableProperty] private decimal? _cols = 5m;
[ObservableProperty] private CellShape _selectedCellShape = CellShape.Round;
[ObservableProperty] private decimal? _padding = 5m;
// ROI
[ObservableProperty] private decimal? _roiX = 0m;
[ObservableProperty] private decimal? _roiY = 0m;
[ObservableProperty] private decimal? _roiWidth = 0m;
[ObservableProperty] private decimal? _roiHeight = 0m;
// Detection thresholds (LAB-based, see LeerformPatternRecognitionService.DetectChocolateMask)
[ObservableProperty] private decimal? _aThreshold = 140m;
[ObservableProperty] private decimal? _lThreshold = 100m;
[ObservableProperty] private decimal? _minBlobArea = 100m;
[ObservableProperty] private decimal? _maxBlobArea = 5000m;
// Recipe
[ObservableProperty] private string _currentRecipeName = "default";
public CellShape[] AvailableCellShapes { get; } = Enum.GetValues<CellShape>();
public CalibrationWindowViewModel(
IImageSource imageSource,
LeerformSettings settings,
SingleCameraVM singleCameraVm,
LeerformPatternRecognitionService patternService,
LeerformRecipeStore recipeStore)
{
_imageSource = imageSource;
_settings = settings;
_singleCameraVm = singleCameraVm;
_patternService = patternService;
_recipeStore = recipeStore;
_calibrationImageDirectory = settings.CalibrationImageDirectory;
var calibrationData = CalibrationDataStore.Load();
_isCalibrated = calibrationData is { CameraMatrix.Length: > 0 };
_rmsError = calibrationData?.RmsError ?? 0;
if (_isCalibrated)
LoadCalibrationMats(calibrationData!);
UpdateCapturedImageCount();
_currentRecipeName = _singleCameraVm.SelectedRecipe?.RecipeName ?? "default";
_singleCameraVm.PropertyChanged += OnSingleCameraVmPropertyChanged;
TryLoadRecipe(_currentRecipeName);
}
private void OnSingleCameraVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(SingleCameraVM.SelectedRecipe))
return;
var name = _singleCameraVm.SelectedRecipe?.RecipeName ?? "default";
Dispatcher.UIThread.Post(() =>
{
CurrentRecipeName = name;
TryLoadRecipe(name);
});
}
private void UpdateCapturedImageCount()
{
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);
CalibrationDataStore.Save(data);
_settings.CalibrationImageDirectory = CalibrationImageDirectory;
LoadCalibrationMats(data);
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";
}
[RelayCommand]
private void TestPattern() => RunTestPattern();
private void RunTestPattern()
{
using var frame = GetAnalysisFrame();
if (frame == null)
{
StatusText = "No active frame to test";
return;
}
try
{
var recipe = BuildCurrentRecipe();
var results = _patternService.EvaluateCells(frame, recipe);
using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results);
var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay);
var old = PreviewImage;
PreviewImage = bitmap;
old?.Dispose();
_referenceFrame?.Dispose();
_referenceFrame = frame.Clone();
var bad = results.Count(r => !r.IsGood);
StatusText = $"Test: {results.Count - bad} good / {bad} bad";
}
catch (Exception ex)
{
Log.Error(ex, "Failed to test pattern");
StatusText = $"Test error: {ex.Message}";
}
}
private Mat? GetAnalysisFrame()
{
if (_lastFrame == null || _lastFrame.Empty())
return null;
var frame = new Mat();
if (_calibCameraMatrix != null && _calibDistCoeffs != null)
Cv2.Undistort(_lastFrame, frame, _calibCameraMatrix, _calibDistCoeffs);
else
_lastFrame.CopyTo(frame);
return frame;
}
[RelayCommand]
private void SaveRecipe()
{
try
{
var recipe = BuildCurrentRecipe();
recipe.RecipeName = CurrentRecipeName;
if (_referenceFrame != null && !_referenceFrame.Empty())
{
using var thumb = new Mat();
Cv2.Resize(_referenceFrame, thumb, new Size(128, 128), 0, 0, InterpolationFlags.Area);
var bytes = thumb.ImEncode(".png");
recipe.ThumbnailBase64 = Convert.ToBase64String(bytes);
}
_recipeStore.Save(recipe);
StatusText = $"Saved recipe '{recipe.RecipeName}'";
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save recipe");
StatusText = $"Save error: {ex.Message}";
}
}
private void TryLoadRecipe(string recipeName)
{
try
{
var recipe = _recipeStore.Load(recipeName);
if (recipe == null)
return;
Rows = recipe.Pattern.Rows;
Cols = recipe.Pattern.Cols;
SelectedCellShape = recipe.Pattern.Shape;
Padding = recipe.Pattern.Padding;
RoiX = recipe.Pattern.RoiX;
RoiY = recipe.Pattern.RoiY;
RoiWidth = recipe.Pattern.RoiWidth;
RoiHeight = recipe.Pattern.RoiHeight;
AThreshold = recipe.AThreshold;
LThreshold = recipe.LThreshold;
MinBlobArea = recipe.MinBlobArea;
MaxBlobArea = recipe.MaxBlobArea;
_referenceFrame?.Dispose();
_referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe);
StatusText = $"Loaded recipe '{recipeName}'";
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to load recipe '{Recipe}'", recipeName);
}
}
private CellPattern BuildCurrentPattern() => new()
{
Rows = (int)(Rows ?? 1m),
Cols = (int)(Cols ?? 1m),
Shape = SelectedCellShape,
Padding = (int)(Padding ?? 0m),
RoiX = (int)(RoiX ?? 0m),
RoiY = (int)(RoiY ?? 0m),
RoiWidth = (int)(RoiWidth ?? 0m),
RoiHeight = (int)(RoiHeight ?? 0m)
};
private LeerformRecipe BuildCurrentRecipe() => new()
{
RecipeName = CurrentRecipeName,
Pattern = BuildCurrentPattern(),
AThreshold = (int)(AThreshold ?? 140m),
LThreshold = (int)(LThreshold ?? 100m),
MinBlobArea = (int)(MinBlobArea ?? 100m),
MaxBlobArea = (int)(MaxBlobArea ?? 5000m)
};
private async Task PreviewLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var frame = await _imageSource.GetImage(ct);
_lastFrame?.Dispose();
_lastFrame = frame;
if ((RoiWidth ?? 0m) <= 0m || (RoiHeight ?? 0m) <= 0m)
{
var w = frame.Cols;
var h = frame.Rows;
await Dispatcher.UIThread.InvokeAsync(() =>
{
RoiX = 0;
RoiY = 0;
RoiWidth = w;
RoiHeight = h;
});
}
var bitmap = RenderFrame(frame);
await Dispatcher.UIThread.InvokeAsync(() =>
{
var old = PreviewImage;
PreviewImage = bitmap;
old?.Dispose();
});
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
await Dispatcher.UIThread.InvokeAsync(() =>
StatusText = $"Preview error: {ex.Message}");
break;
}
}
}
private static readonly Scalar GridColor = new(0, 255, 255); // BGR yellow
private Avalonia.Media.Imaging.Bitmap RenderFrame(Mat frame)
{
var displayFrame = frame;
var owned = false;
if (ApplyCalibration && _calibCameraMatrix != null && _calibDistCoeffs != null)
{
displayFrame = new Mat();
Cv2.Undistort(frame, displayFrame, _calibCameraMatrix, _calibDistCoeffs);
owned = true;
}
var pattern = BuildCurrentPattern();
if (pattern.Rows > 0 && pattern.Cols > 0 && pattern.RoiWidth > 0 && pattern.RoiHeight > 0)
{
if (!owned)
{
displayFrame = frame.Clone();
owned = true;
}
_patternService.DrawPattern(displayFrame, pattern, GridColor);
}
var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame);
if (owned)
displayFrame.Dispose();
return bitmap;
}
private void RefreshPreview()
{
if (_lastFrame == null || _lastFrame.Empty()) return;
try
{
var bitmap = RenderFrame(_lastFrame);
var old = PreviewImage;
PreviewImage = bitmap;
old?.Dispose();
}
catch (Exception ex)
{
StatusText = $"Preview error: {ex.Message}";
}
}
partial void OnApplyCalibrationChanged(bool value) => RefreshPreview();
partial void OnRowsChanged(decimal? value) => RefreshPreview();
partial void OnColsChanged(decimal? value) => RefreshPreview();
partial void OnSelectedCellShapeChanged(CellShape value) => RefreshPreview();
partial void OnPaddingChanged(decimal? value) => RefreshPreview();
partial void OnRoiXChanged(decimal? value) => RefreshPreview();
partial void OnRoiYChanged(decimal? value) => RefreshPreview();
partial void OnRoiWidthChanged(decimal? value) => RefreshPreview();
partial void OnRoiHeightChanged(decimal? value) => RefreshPreview();
partial void OnAThresholdChanged(decimal? value) => RefreshPreview();
partial void OnLThresholdChanged(decimal? value) => RefreshPreview();
partial void OnMinBlobAreaChanged(decimal? value) => RefreshPreview();
partial void OnMaxBlobAreaChanged(decimal? value) => RefreshPreview();
private void LoadCalibrationMats(Models.CalibrationData data)
{
_calibCameraMatrix?.Dispose();
_calibDistCoeffs?.Dispose();
(_calibCameraMatrix, _calibDistCoeffs) = CameraCalibrationService.LoadCalibrationMats(data);
}
public void Dispose()
{
_singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged;
StopPreview();
_lastFrame?.Dispose();
_referenceFrame?.Dispose();
_calibCameraMatrix?.Dispose();
_calibDistCoeffs?.Dispose();
}
}

View File

@@ -0,0 +1,146 @@
<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">
<Window.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="Red" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFEBEE" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#FFCDD2" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
</Window.Styles>
<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="290" Padding="12" Background="White">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="8" Margin="0,0,20,0">
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
<Button Content="CALIBRATE" Command="{Binding RunCalibrationCommand}" Margin="0,4" />
<Button Content="CLEAR IMAGES" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4" />
<Separator Margin="0,8" />
<TextBlock Text="Calibrated" Foreground="LimeGreen" IsVisible="{Binding IsCalibrated}" />
<TextBlock Foreground="Gray" IsVisible="{Binding IsCalibrated}">
<TextBlock.Text>
<MultiBinding StringFormat="RMS Error: {0:F4}">
<Binding Path="RmsError" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<CheckBox Content="Apply Calibration" IsChecked="{Binding ApplyCalibration}"
IsEnabled="{Binding IsCalibrated}" Foreground="Black" Margin="0,4" />
<Separator Margin="0,8" />
<TextBlock Text="Pattern" FontWeight="Bold" FontSize="14" Foreground="Black" />
<TextBlock Text="Rows" Foreground="Black" />
<NumericUpDown Value="{Binding Rows}" Minimum="1" Maximum="200" Increment="1" FormatString="0" />
<TextBlock Text="Cols" Foreground="Black" />
<NumericUpDown Value="{Binding Cols}" Minimum="1" Maximum="200" Increment="1" FormatString="0" />
<TextBlock Text="Shape" Foreground="Black" />
<ComboBox ItemsSource="{Binding AvailableCellShapes}" SelectedItem="{Binding SelectedCellShape}" HorizontalAlignment="Stretch" />
<TextBlock Text="Padding (px)" Foreground="Black" />
<NumericUpDown Value="{Binding Padding}" Minimum="0" Maximum="500" Increment="1" FormatString="0" />
<Separator Margin="0,8" />
<TextBlock Text="ROI (px)" FontWeight="Bold" FontSize="14" Foreground="Black" />
<TextBlock Text="X" Foreground="Black" />
<NumericUpDown Value="{Binding RoiX}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
<TextBlock Text="Y" Foreground="Black" />
<NumericUpDown Value="{Binding RoiY}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
<TextBlock Text="Width" Foreground="Black" />
<NumericUpDown Value="{Binding RoiWidth}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
<TextBlock Text="Height" Foreground="Black" />
<NumericUpDown Value="{Binding RoiHeight}" Minimum="0" Maximum="100000" Increment="1" FormatString="0" />
<Separator Margin="0,8" />
<TextBlock Text="Detection Thresholds" FontWeight="Bold" FontSize="14" Foreground="Black" />
<TextBlock Text="A* (white chocolate, lower = stricter)" Foreground="Black" />
<NumericUpDown Value="{Binding AThreshold}" Minimum="0" Maximum="255" Increment="1" FormatString="0" />
<TextBlock Text="L* (dark chocolate, lower = stricter)" Foreground="Black" />
<NumericUpDown Value="{Binding LThreshold}" Minimum="0" Maximum="255" Increment="1" FormatString="0" />
<TextBlock Text="Min blob area (px)" Foreground="Black" />
<NumericUpDown Value="{Binding MinBlobArea}" Minimum="0" Maximum="100000" Increment="10" FormatString="0" />
<TextBlock Text="Max blob area (px)" Foreground="Black" />
<NumericUpDown Value="{Binding MaxBlobArea}" Minimum="0" Maximum="1000000" Increment="100" FormatString="0" />
<Button Content="TEST PATTERN" Command="{Binding TestPatternCommand}" Margin="0,8,0,4"
HorizontalAlignment="Stretch" />
<Separator Margin="0,8" />
<TextBlock Text="Recipe" FontWeight="Bold" FontSize="14" Foreground="Black" />
<TextBlock Foreground="Black">
<TextBlock.Text>
<MultiBinding StringFormat="Current: {0}">
<Binding Path="CurrentRecipeName" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<Button Content="SAVE RECIPE" Command="{Binding SaveRecipeCommand}" Margin="0,4"
HorizontalAlignment="Stretch" />
</StackPanel>
</ScrollViewer>
</Border>
<!-- Live Preview -->
<Border Background="#1A1A1A" Margin="4">
<Image x:Name="PreviewImageControl"
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,33 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LindtLeerformPlugin.Views.RecipeNameDialog"
Title="New Recipe"
Width="400" Height="180"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto" Margin="16">
<TextBlock Grid.Row="0"
Text="Recipe name:"
FontSize="14"
Margin="0,0,0,8" />
<TextBox Grid.Row="1"
x:Name="TxtRecipeName"
Margin="0,0,0,16" />
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8">
<Button x:Name="BtnOk"
Content="OK"
Width="80" Height="36"
HorizontalContentAlignment="Center" />
<Button x:Name="BtnCancel"
Content="Cancel"
Width="80" Height="36"
HorizontalContentAlignment="Center" />
</StackPanel>
</Grid>
</Window>

View File

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

View File

@@ -0,0 +1,23 @@
{
"CameraMatrix": [
2520.5848610442777,
0,
903.5347706837581,
0,
2522.5455493789846,
714.9728204617296,
0,
0,
1
],
"DistCoeffs": [
-0.17606445822785338,
0.8803211909256239,
3.4191194540339253E-05,
0.003768837119214187,
-3.0648022036508693
],
"RmsError": 0.9078398532362246,
"ImageWidth": 2028,
"ImageHeight": 1520
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutDir>..\..\..\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\PackstrasseBarcodeReader</OutDir>
<OutDir>..\..\..\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\PackstrasseBarcodeReader</OutDir>
</PropertyGroup>
<ItemGroup>

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutDir>D:\Inspectron\Hawkeye\code\VisionBuilder5\VisionBuilder.UI\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\PralinenPLC</OutDir>
<OutDir>D:\Inspectron\Hawkeye\code\VisionBuilder5\VisionBuilder.UI\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\PralinenPLC</OutDir>
</PropertyGroup>
<ItemGroup>

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutDir>..\..\..\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\B24SiemensPlugin</OutDir>
<OutDir>..\..\..\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\B24SiemensPlugin</OutDir>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\framework\Inspectron.Settings\Inspectron.Settings.csproj">

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutDir>D:\Inspectron\Hawkeye\code\VisionBuilder5\VisionBuilder.UI\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\TestPlugin</OutDir>
<OutDir>D:\Inspectron\Hawkeye\code\VisionBuilder5\VisionBuilder.UI\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\TestPlugin</OutDir>
</PropertyGroup>

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,95 @@
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.Services;
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;
using VisionBuilder.UI.Avalonia.Uno.Views;
using Lindt.Colorballs.Duo.Utils;
using VisionBuilder.UI.Camera;
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 profile = commandLineParser.GetStringArgument("profile", 'p');
var settings = new InspectronSettings("../Data/Config", profile);
var mainKernel = Common.VisionBuilder.CreateMainKernel(settings);
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>();
var uiConfiguration = mainKernel.Get<UIConfiguration>();
var passwordInputService = mainKernel.Get<IPasswordInputService>();
settings.LoadSettings();
desktop.MainWindow = new MainWindow(mainWindowVm, avaloniaUiSettings, uiConfiguration, passwordInputService);
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,27 @@
using Avalonia;
using Avalonia.LinuxFramebuffer;
using Serilog;
namespace VisionBuilder.UI.Avalonia.Uno;
public class Program
{
[STAThread]
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.CreateLogger();
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,64 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:VisionBuilder.UI.Avalonia.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="BtnSettings"
Content="SETTINGS"
Width="176" Height="48"
FontSize="14" FontWeight="Bold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
BorderBrush="#D32F2F"
BorderThickness="2"
Background="White"
Foreground="#D32F2F" />
<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,217 @@
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.Avalonia;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Services;
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 UIConfiguration _uiConfiguration;
private readonly IPasswordInputService _passwordInputService;
private readonly Dictionary<string, Control> _dynamicButtonControls = new();
public MainWindow(MainWindowVM mainWindowVm, AvaloniaUISettings avaloniaUiSettings,
UIConfiguration uiConfiguration, IPasswordInputService passwordInputService)
{
_mainWindowVm = mainWindowVm;
_avaloniaUiSettings = avaloniaUiSettings;
_uiConfiguration = uiConfiguration;
_passwordInputService = passwordInputService;
InitializeComponent();
DataContext = _mainWindowVm;
SingleCameraView.SetViewModel(_mainWindowVm.SingleCameraVms[0]);
BtnSettings.Click += BtnSettings_Click;
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 async void BtnSettings_Click(object? sender, RoutedEventArgs e)
{
if (_uiConfiguration.ProtectSettingsWithPassword)
{
var passwordOk = await _passwordInputService.GetPasswordAsync() == _uiConfiguration.AdminPassword;
if (!passwordOk)
return;
}
_mainWindowVm.SettingsCommand.Execute(null);
}
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,39 @@
<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.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.LinuxFramebuffer" Version="11.2.3" />
<PackageReference Include="Ninject.Extensions.ChildKernel" Version="3.3.0" />
<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" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\VisionBuilder.UI.Avalonia\VisionBuilder.UI.Avalonia.csproj" />
<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

@@ -0,0 +1,14 @@
using Inspectron.Settings;
using VisionBuilder.UI.Common;
namespace VisionBuilder.UI.Avalonia;
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.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.Services;
namespace VisionBuilder.UI.Avalonia.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.Services;
namespace VisionBuilder.UI.Avalonia;
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,26 @@
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
using VisionBuilder.UI.Avalonia.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.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.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.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.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.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
namespace VisionBuilder.UI.Avalonia.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,38 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Inspectron.Settings;
using Inspectron.Settings.Avalonia.Configuration;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Avalonia.Services;
public class AvaloniaSettingsService : ISettingsService
{
private readonly InspectronSettings _settings;
public AvaloniaSettingsService(InspectronSettings settings)
{
_settings = settings;
}
public async Task ShowSettingsDialogAsync()
{
var dialog = new OptionsWindow(new DefaultControlFactory());
dialog.LoadFromSettings(_settings);
var parent = GetMainWindow();
if (parent != null)
{
var result = await dialog.ShowDialog<bool>(parent);
if (result)
_settings.SaveSettings();
}
}
private static Window? GetMainWindow()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return desktop.MainWindow;
return null;
}
}

View File

@@ -0,0 +1,13 @@
using OpenCvSharp;
namespace VisionBuilder.UI.Avalonia.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.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.Services;
using VisionBuilder.UI.Common.ViewModel;
namespace VisionBuilder.UI.Avalonia.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,34 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VisionBuilder.UI.Avalonia.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.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.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,130 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using VisionBuilder.UI.Avalonia.Services;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
using VisionBuilder.UI.Common.ViewModel.Interfaces.UI;
namespace VisionBuilder.UI.Avalonia.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 async void BtnCreateNew_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
{
var recipeName = await _recipeCreationTool.CreateRecipeAsync();
if (recipeName != null)
{
_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.Converters"
x:Class="VisionBuilder.UI.Avalonia.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.Services;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.ViewModel;
using VisionBuilder.UI.Common.ViewModel.Classes;
namespace VisionBuilder.UI.Avalonia.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,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<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="OpenCvSharp4" Version="4.10.0.20241108" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\framework\Inspectron.Settings\Inspectron.Settings.csproj" />
<ProjectReference Include="..\framework\Inspectron.Settings.Avalonia\Inspectron.Settings.Avalonia.csproj" />
<ProjectReference Include="..\VisionBuilder.UI.Common\VisionBuilder.UI.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -47,6 +47,7 @@ public partial class MainLayout : IDisposable
};
private MudTheme _currentTheme = null!;
private bool _disposed;
protected override void OnInitialized()
{
@@ -71,6 +72,7 @@ public partial class MainLayout : IDisposable
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_disposed) return;
if (e.PropertyName == nameof(MainWindowVM.TestMode))
{
_testMode = MainWindowVm.TestMode;
@@ -98,6 +100,7 @@ public partial class MainLayout : IDisposable
public void Dispose()
{
_disposed = true;
MainWindowVm.PropertyChanged -= OnPropertyChanged;
}
}

View File

@@ -4,6 +4,7 @@ using MudBlazor.Services;
using Ninject;
using Ninject.Extensions.ChildKernel;
using VisionBuilder.UI.Blazor;
using VisionBuilder.UI.Camera;
using VisionBuilder.UI.Common;
using VisionBuilder.UI.Common.Plugins;
using VisionBuilder.UI.Common.Processing;
@@ -25,12 +26,13 @@ builder.Services.AddMudServices();
// Parse command line args
CommandLineParser commandLineParser = new CommandLineParser(args);
var profile = commandLineParser.GetStringArgument("profile", 'p');
// Setup settings
InspectronSettings settings = new InspectronSettings("..\\Data\\Config");
InspectronSettings settings = new InspectronSettings(Path.Combine("..", "Data", "Config"), profile);
var mainKernel = VisionBuilder.UI.Common.VisionBuilder.CreateMainKernel(settings);
var profile = commandLineParser.GetStringArgument("profile", 'p');
mainKernel.UsePlugins(profile);
mainKernel

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -16,12 +16,12 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.0.0" />
<PackageReference Include="Ninject.Extensions.ChildKernel" Version="3.3.0" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.6.0.20220608" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20241108" />
<PackageReference Include="swety.OpenCvSharp4.runtime.linux-arm64" Version="4.10.0.20241108" />
</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.Console\VisionBuilder.UI.Console.csproj" />

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.5",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -12,6 +12,7 @@ public partial class ErrorPreview : ComponentBase, IDisposable
private List<ErrorData> _errors = new();
private ErrorsVM? _subscribedVm;
private bool _disposed;
protected override void OnParametersSet()
{
@@ -41,6 +42,7 @@ public partial class ErrorPreview : ComponentBase, IDisposable
private async void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (_disposed) return;
if (ViewModel != null)
_errors = new List<ErrorData>(ViewModel.Errors);
await InvokeAsync(StateHasChanged);
@@ -54,6 +56,7 @@ public partial class ErrorPreview : ComponentBase, IDisposable
public void Dispose()
{
_disposed = true;
Unsubscribe();
}
}

View File

@@ -15,6 +15,7 @@ public partial class PreviewWindow : ComponentBase, IDisposable
private DateTime _lastRenderTime = DateTime.MinValue;
private Timer? _pendingTimer;
private PreviewVM? _subscribedVm;
private bool _disposed;
protected override void OnParametersSet()
{
@@ -69,6 +70,8 @@ public partial class PreviewWindow : ComponentBase, IDisposable
private async Task RenderCurrentFrame()
{
if (_disposed) return;
_lastRenderTime = DateTime.UtcNow;
var mat = ViewModel?.ImagePreview;
@@ -88,6 +91,7 @@ public partial class PreviewWindow : ComponentBase, IDisposable
public void Dispose()
{
_disposed = true;
_pendingTimer?.Dispose();
Unsubscribe();
}

View File

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

@@ -18,6 +18,7 @@ public partial class SingleCameraControl : ComponentBase, IDisposable
private bool _canHotReload;
private bool _showPauseButton;
private SingleCameraVM? _subscribedVm;
private bool _disposed;
protected override void OnParametersSet()
{
@@ -61,6 +62,7 @@ public partial class SingleCameraControl : ComponentBase, IDisposable
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_disposed) return;
UpdateState();
await InvokeAsync(StateHasChanged);
}
@@ -103,6 +105,7 @@ public partial class SingleCameraControl : ComponentBase, IDisposable
public void Dispose()
{
_disposed = true;
Unsubscribe();
}
}

View File

@@ -17,6 +17,7 @@ public partial class Stats : ComponentBase, IDisposable
private string _errorRate = "";
private string _statisticsDetails = "";
private StatisticsVM? _subscribedVm;
private bool _disposed;
protected override void OnParametersSet()
{
@@ -43,6 +44,8 @@ public partial class Stats : ComponentBase, IDisposable
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (_disposed) return;
_sessionStarted = ViewModel?.SessionStarted ?? "";
_recipeName = ViewModel?.RecipeName ?? "";
_processingTime = ViewModel?.ProcessingTime ?? "";
@@ -57,6 +60,7 @@ public partial class Stats : ComponentBase, IDisposable
public void Dispose()
{
_disposed = true;
Unsubscribe();
}
}

View File

@@ -48,14 +48,24 @@
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public ErrorPreviewVM? ViewModel { get; set; }
private ErrorPreviewVM? _subscribedVm;
private bool _disposed;
protected override void OnParametersSet()
{
if (ViewModel != null)
ViewModel.PropertyChanged += OnPropertyChanged;
if (_subscribedVm != ViewModel)
{
if (_subscribedVm != null)
_subscribedVm.PropertyChanged -= OnPropertyChanged;
_subscribedVm = ViewModel;
if (ViewModel != null)
ViewModel.PropertyChanged += OnPropertyChanged;
}
}
private async void OnPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (_disposed) return;
await InvokeAsync(StateHasChanged);
}
@@ -72,7 +82,8 @@
public void Dispose()
{
if (ViewModel != null)
ViewModel.PropertyChanged -= OnPropertyChanged;
_disposed = true;
if (_subscribedVm != null)
_subscribedVm.PropertyChanged -= OnPropertyChanged;
}
}

Some files were not shown because too many files have changed in this diff Show More