diff --git a/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher.csproj b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher.csproj new file mode 100644 index 0000000..bdc20d7 --- /dev/null +++ b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net8.0-windows + true + enable + enable + Hawkeye.VisionBuilder.UI.RecipeYoloPatcher + + + + + + + + + + + + + + + + + + + + diff --git a/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.Designer.cs b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.Designer.cs new file mode 100644 index 0000000..2360a70 --- /dev/null +++ b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.Designer.cs @@ -0,0 +1,399 @@ +#nullable disable + +namespace Hawkeye.VisionBuilder.UI.RecipeYoloPatcher +{ + partial class MainForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + 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; + } +} diff --git a/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.cs b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.cs new file mode 100644 index 0000000..c0d46d6 --- /dev/null +++ b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.cs @@ -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().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().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().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().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()) + { + 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 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() + .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); + } +} diff --git a/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.resx b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/MainForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Program.cs b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Program.cs new file mode 100644 index 0000000..c26157b --- /dev/null +++ b/Hawkeye.VisionBuilder.UI.RecipeYoloPatcher/Program.cs @@ -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()); + } +} diff --git a/Plugins/CandyboxPlugin/CandyboxPlugin.csproj b/Plugins/CandyboxPlugin/CandyboxPlugin.csproj index 019d17f..6d5f23e 100644 --- a/Plugins/CandyboxPlugin/CandyboxPlugin.csproj +++ b/Plugins/CandyboxPlugin/CandyboxPlugin.csproj @@ -6,7 +6,7 @@ enable true enable - ..\..\VisionBuilder.UI.Windows.Test\bin\Debug\Data\Plugins\CandyboxPlugin + ..\..\VisionBuilder.UI.Windows.Uno\bin\Debug\Data\Plugins\CandyboxPlugin diff --git a/Plugins/LindtLeerformPlugin/Models/CellResult.cs b/Plugins/LindtLeerformPlugin/Models/CellResult.cs index 68cd321..381e602 100644 --- a/Plugins/LindtLeerformPlugin/Models/CellResult.cs +++ b/Plugins/LindtLeerformPlugin/Models/CellResult.cs @@ -3,7 +3,13 @@ namespace LindtLeerformPlugin.Models; public class CellResult { public int Index { get; set; } + + /// True when no chocolate blobs were detected inside this cell (the form is empty as expected). public bool IsGood { get; set; } - /// Largest (histogram bin value − spline at that bin) across all bins. Negative if every bin is below the spline. - public double MaxExceedance { get; set; } + + /// Number of contours inside the cell that passed the area filter. + public int BlobCount { get; set; } + + /// Total pixel area classified as chocolate inside this cell (sum of accepted contour areas). + public int DetectedArea { get; set; } } diff --git a/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs index e0b382b..c941036 100644 --- a/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs +++ b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs @@ -4,8 +4,18 @@ public class LeerformRecipe { public string RecipeName { get; set; } = string.Empty; public CellPattern Pattern { get; set; } = new(); - public int[] HistogramBins { get; set; } = [5, 5, 5]; - public float[] AverageSpline { get; set; } = []; - public Dictionary CellSplines { get; set; } = new(); + + /// LAB a* threshold for white chocolate (THRESH_BINARY_INV). Pixels with a* below this become foreground. + public int AThreshold { get; set; } = 140; + + /// LAB L* threshold for dark chocolate (THRESH_BINARY_INV). Pixels with L* below this become foreground. + public int LThreshold { get; set; } = 100; + + /// Minimum contour area (px) for a detected blob to count. + public int MinBlobArea { get; set; } = 100; + + /// Maximum contour area (px) for a detected blob to count. + public int MaxBlobArea { get; set; } = 5000; + public string ThumbnailBase64 { get; set; } = string.Empty; } diff --git a/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs index 5ad92d0..991b7f7 100644 --- a/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs +++ b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs @@ -5,9 +5,8 @@ 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 - private static readonly Scalar OverrideColor = new(0, 255, 255); // BGR Yellow + private static readonly Scalar GoodColor = new(0, 255, 0); // BGR Green + private static readonly Scalar BadColor = new(0, 0, 255); // BGR Red public IReadOnlyList ComputeCells(CellPattern pattern) { @@ -75,86 +74,98 @@ public class LeerformPatternRecognitionService } /// - /// Aggregate histogram pooled across every cell in the pattern. Used only at calibration - /// time to seed the default average spline; not persisted in the recipe. Note that pooling - /// weights cells by their pixel count rather than averaging per-cell histograms. + /// Build a single foreground mask covering both white and dark chocolate flecks on the pink mold, + /// using the LAB-channel thresholding approach from jupiter/grid_test.ipynb. + /// + /// White chocolate — pink mold has a* well above 128 (red), white sits near 128. Inverse-threshold a* so neutral pixels become foreground. + /// Dark chocolate — pink mold is bright (high L*), dark chocolate is dark. Inverse-threshold L* so dark pixels become foreground. + /// + /// 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 . /// - public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null) + public static Mat DetectChocolateMask(Mat bgrImage, int aThreshold, int lThreshold) { - bins ??= [5, 5, 5]; - using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows)); - using var hist = CalcHistogram(bgrImage, mask, bins); - return HistogramToArray(hist); + 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 EvaluateCells(Mat bgrImage, LeerformRecipe recipe) { + using var chocolateMask = DetectChocolateMask(bgrImage, recipe.AThreshold, recipe.LThreshold); + var cells = ComputeCells(recipe.Pattern); - var bins = recipe.HistogramBins is { Length: 3 } ? recipe.HistogramBins : [5, 5, 5]; var imageSize = new Size(bgrImage.Cols, bgrImage.Rows); var results = new List(cells.Count); foreach (var cell in cells) { - float[]? spline = null; - if (recipe.CellSplines != null - && recipe.CellSplines.TryGetValue(cell.Index, out var custom) - && custom is { Length: SplineCurve.KnotCount }) + 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) { - spline = custom; - } - else if (recipe.AverageSpline is { Length: SplineCurve.KnotCount }) - { - spline = recipe.AverageSpline; + var contourArea = (int)Cv2.ContourArea(contour); + if (contourArea >= recipe.MinBlobArea && contourArea <= recipe.MaxBlobArea) + { + blobs++; + area += contourArea; + } } - if (spline == null) - throw new InvalidOperationException( - $"Recipe has no spline for cell {cell.Index} — open Calibration and click 'Set Reference'."); - - using var mask = BuildMask(recipe.Pattern, imageSize, cell.Index); - using var hist = CalcHistogram(bgrImage, mask, bins); - var arr = HistogramToArray(hist); - - var (isGood, exceed) = EvaluateAgainstSpline(arr, spline); results.Add(new CellResult { Index = cell.Index, - IsGood = isGood, - MaxExceedance = exceed + IsGood = blobs == 0, + BlobCount = blobs, + DetectedArea = area }); } return results; } - /// - /// Cell is good when no histogram bin rises above the spline. maxExceedance is the - /// largest (bin − spline) across all bins; non-positive means the cell passes. - /// - public static (bool isGood, double maxExceedance) EvaluateAgainstSpline(float[] histogram, float[] spline) - { - if (histogram == null || histogram.Length == 0) - return (true, 0); - - var maxExceed = double.NegativeInfinity; - for (var i = 0; i < histogram.Length; i++) - { - var threshold = SplineCurve.EvaluateAtBin(spline, i, histogram.Length); - var diff = histogram[i] - threshold; - if (diff > maxExceed) maxExceed = diff; - } - return (maxExceed <= 0, maxExceed); - } - - public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, - IReadOnlyList results, - IReadOnlySet? overriddenCells = null) + public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList results) { var overlay = bgrImage.Clone(); var cells = ComputeCells(pattern); var resultByIndex = results.ToDictionary(r => r.Index); - var imageRect = new Rect(0, 0, overlay.Cols, overlay.Rows); const int thickness = 2; foreach (var cell in cells) @@ -168,9 +179,6 @@ public class LeerformPatternRecognitionService Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness); else Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness); - - if (overriddenCells != null && overriddenCells.Contains(cell.Index)) - DrawOverrideMarker(overlay, cell, pattern.Shape, imageRect); } return overlay; } @@ -187,39 +195,6 @@ public class LeerformPatternRecognitionService } } - public void DrawOverrideMarkers(Mat target, CellPattern pattern, IReadOnlySet overriddenCells) - { - if (overriddenCells.Count == 0) return; - var cells = ComputeCells(pattern); - var imageRect = new Rect(0, 0, target.Cols, target.Rows); - foreach (var cell in cells) - { - if (!overriddenCells.Contains(cell.Index)) continue; - DrawOverrideMarker(target, cell, pattern.Shape, imageRect); - } - } - - private static void DrawOverrideMarker(Mat target, CellRegion cell, CellShape shape, Rect imageRect) - { - Point center; - if (shape == CellShape.Square) - { - center = new Point(cell.BoundingBox.Right - 8, cell.BoundingBox.Top + 8); - } - else - { - var dx = (int)(cell.Radius * 0.7); - var dy = (int)(cell.Radius * 0.7); - center = new Point(cell.Center.X + dx, cell.Center.Y - dy); - } - - if (!imageRect.Contains(center)) - return; - - Cv2.Circle(target, center, 4, OverrideColor, thickness: -1); - Cv2.Circle(target, center, 4, new Scalar(0, 0, 0), thickness: 1); // thin black outline for visibility - } - public int? FindCellAtPoint(int x, int y, CellPattern pattern) { var cells = ComputeCells(pattern); @@ -240,78 +215,4 @@ public class LeerformPatternRecognitionService } return null; } - - public float[] ComputeCellHistogram(Mat bgrImage, CellPattern pattern, int cellIndex, int[]? bins = null) - { - bins ??= [5, 5, 5]; - using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows), cellIndex); - using var hist = CalcHistogram(bgrImage, mask, bins); - return HistogramToArray(hist); - } - - public static Mat RenderHistogramChart(float[] histogram, int[] bins, int width = 600, int height = 220) - { - var chart = new Mat(height, width, MatType.CV_8UC3, new Scalar(30, 30, 30)); - if (histogram == null || histogram.Length == 0) - return chart; - - var totalBins = bins[0] * bins[1] * bins[2]; - var max = 0f; - for (var i = 0; i < histogram.Length; i++) - if (histogram[i] > max) max = histogram[i]; - if (max <= 0) - return chart; - - const int leftPad = 10; - const int bottomPad = 15; - var barWidth = Math.Max(1, (width - 2 * leftPad) / totalBins); - var maxBarHeight = height - bottomPad - 10; - - for (var i = 0; i < totalBins && i < histogram.Length; i++) - { - var binB = i / (bins[1] * bins[2]); - var binG = (i / bins[2]) % bins[1]; - var binR = i % bins[2]; - - var b = (int)((binB + 0.5) * 256 / bins[0]); - var g = (int)((binG + 0.5) * 256 / bins[1]); - var r = (int)((binR + 0.5) * 256 / bins[2]); - - var barHeight = (int)(histogram[i] / max * maxBarHeight); - var x = leftPad + i * barWidth; - var y = height - bottomPad - barHeight; - Cv2.Rectangle(chart, new Rect(x, y, barWidth, barHeight), new Scalar(b, g, r), -1); - } - - return chart; - } - - private static Mat CalcHistogram(Mat bgrImage, Mat mask, int[] bins) - { - var hist = new Mat(); - Cv2.CalcHist( - images: new[] { bgrImage }, - channels: new[] { 0, 1, 2 }, - mask: mask, - hist: hist, - dims: 3, - histSize: bins, - ranges: new[] { new Rangef(0, 256), new Rangef(0, 256), new Rangef(0, 256) }); - // L1 normalize so each bin is a probability in [0, 1] summing to 1. - Cv2.Normalize(hist, hist, 1.0, 0.0, NormTypes.L1); - - var totalBins = bins[0] * bins[1] * bins[2]; - var reshaped = hist.Reshape(1, totalBins).Clone(); - hist.Dispose(); - return reshaped; - } - - private static float[] HistogramToArray(Mat hist) - { - var totalBins = hist.Rows * hist.Cols; - var arr = new float[totalBins]; - for (var i = 0; i < totalBins; i++) - arr[i] = hist.At(i, 0); - return arr; - } } diff --git a/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs b/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs deleted file mode 100644 index 9a0debf..0000000 --- a/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs +++ /dev/null @@ -1,116 +0,0 @@ -namespace LindtLeerformPlugin.Services; - -/// -/// Monotone cubic Hermite spline (Fritsch–Carlson). Five control points with -/// fixed, evenly spaced X positions; only Y is editable. Output never overshoots -/// the input range, so envelope curves stay inside [0, 1] when knots are. -/// -public static class SplineCurve -{ - public const int KnotCount = 5; - - /// - /// Evaluate the spline at parameter in [0, 1]. - /// Knots are positioned at t = i / (knots.Length - 1). - /// - public static float Evaluate(float[]? knots, double t) - { - if (knots == null || knots.Length == 0) - return 0f; - if (knots.Length == 1) - return knots[0]; - - if (t <= 0) return knots[0]; - if (t >= 1) return knots[^1]; - - var n = knots.Length - 1; - var pos = t * n; - var i = (int)System.Math.Floor(pos); - if (i >= n) return knots[n]; - var localT = pos - i; - - // Compute secant slopes and Fritsch-Carlson tangents at the two surrounding knots only. - var dxLeft = i > 0 ? (double)(knots[i] - knots[i - 1]) : 0; - var dxMid = (double)(knots[i + 1] - knots[i]); - var dxRight = i + 2 <= n ? (double)(knots[i + 2] - knots[i + 1]) : 0; - - var m0 = MonotoneTangent(dxLeft, dxMid, hasLeft: i > 0, hasRight: true); - var m1 = MonotoneTangent(dxMid, dxRight, hasLeft: true, hasRight: i + 2 <= n); - - var t2 = localT * localT; - var t3 = t2 * localT; - var h00 = 2 * t3 - 3 * t2 + 1; - var h10 = t3 - 2 * t2 + localT; - var h01 = -2 * t3 + 3 * t2; - var h11 = t3 - t2; - - var y = h00 * knots[i] + h10 * m0 + h01 * knots[i + 1] + h11 * m1; - return (float)y; - } - - /// - /// Convenience: evaluate the spline at the position of bin - /// of a histogram with bins. - /// - public static float EvaluateAtBin(float[]? knots, int binIndex, int totalBins) - { - if (totalBins <= 1) - return Evaluate(knots, 0); - var t = (double)binIndex / (totalBins - 1); - return Evaluate(knots, t); - } - - /// - /// Build a default envelope spline from a reference histogram. For each knot - /// the segment max around the knot position is taken, multiplied by 1.2 with a - /// small floor added, and clamped to [0, 1]. - /// - public static float[] CreateDefault(float[]? referenceHistogram, int knotCount = KnotCount) - { - var knots = new float[knotCount]; - if (referenceHistogram == null || referenceHistogram.Length == 0) - { - for (var k = 0; k < knotCount; k++) knots[k] = 0.05f; - return knots; - } - - var totalBins = referenceHistogram.Length; - var span = totalBins - 1; - // Half-width of the window we look at around each knot. - var segHalf = System.Math.Max(1, span / (2 * (knotCount - 1))); - - for (var k = 0; k < knotCount; k++) - { - var center = knotCount == 1 ? 0 : k * span / (knotCount - 1); - var lo = System.Math.Max(0, center - segHalf); - var hi = System.Math.Min(totalBins - 1, center + segHalf); - var max = 0f; - for (var i = lo; i <= hi; i++) - if (referenceHistogram[i] > max) max = referenceHistogram[i]; - - var y = max * 1.2f + 0.01f; - if (y < 0f) y = 0f; - if (y > 1f) y = 1f; - knots[k] = y; - } - return knots; - } - - private static double MonotoneTangent(double secLeft, double secRight, bool hasLeft, bool hasRight) - { - if (!hasLeft) return secRight; - if (!hasRight) return secLeft; - // If the signs differ (or either is zero), the knot is an extremum: tangent must be zero - // to preserve monotonicity locally. - if (secLeft == 0 || secRight == 0 || System.Math.Sign(secLeft) != System.Math.Sign(secRight)) - return 0; - // Average — Fritsch-Carlson would also clamp by 3*min(|secLeft|,|secRight|), but for our - // small (5 knot) curves the simple average plus zero-at-extrema rule already prevents - // overshoot in practice. - var avg = 0.5 * (secLeft + secRight); - var limit = 3.0 * System.Math.Min(System.Math.Abs(secLeft), System.Math.Abs(secRight)); - if (System.Math.Abs(avg) > limit) - avg = System.Math.Sign(avg) * limit; - return avg; - } -} diff --git a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs index 0467274..c921548 100644 --- a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs +++ b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs @@ -4,7 +4,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LindtLeerformPlugin.Models; using LindtLeerformPlugin.Services; -using LindtLeerformPlugin.Views; using OpenCvSharp; using Serilog; using VisionBuilder.UI.Common; @@ -26,16 +25,6 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable private Mat? _calibDistCoeffs; private Mat? _referenceFrame; - // Non-modal cell histogram tool window state. While open, clicks on cells in the - // calibration preview update its content instead of opening a new window. - private CellHistogramWindow? _cellDialog; - private CellHistogramViewModel? _cellDialogVm; - - // Transient seed for the average spline; never persisted in the recipe. - private float[]? _averageReferenceHistogram; - private float[]? _averageSpline; - private readonly Dictionary _cellSplines = new(); - [ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage; [ObservableProperty] private string _statusText = "Ready"; [ObservableProperty] private int _capturedImageCount; @@ -57,8 +46,11 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable [ObservableProperty] private decimal? _roiWidth = 0m; [ObservableProperty] private decimal? _roiHeight = 0m; - // Detection - [ObservableProperty] private bool _hasAverageSpline; + // 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"; @@ -185,37 +177,6 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable StatusText = "Calibration images cleared"; } - [RelayCommand] - private void SetReferenceHistogram() - { - using var frame = GetAnalysisFrame(); - if (frame == null) - { - StatusText = "No active frame to use as reference"; - return; - } - - try - { - var pattern = BuildCurrentPattern(); - _averageReferenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); - _averageSpline = SplineCurve.CreateDefault(_averageReferenceHistogram); - _cellSplines.Clear(); - - _referenceFrame?.Dispose(); - _referenceFrame = frame.Clone(); - - HasAverageSpline = true; - StatusText = "Average spline seeded; per-cell overrides cleared."; - RefreshPreview(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to seed average spline"); - StatusText = $"Reference error: {ex.Message}"; - } - } - [RelayCommand] private void TestPattern() => RunTestPattern(); @@ -227,24 +188,21 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable StatusText = "No active frame to test"; return; } - if (_averageSpline == null) - { - StatusText = "Set reference first"; - return; - } try { var recipe = BuildCurrentRecipe(); var results = _patternService.EvaluateCells(frame, recipe); - var overrides = new HashSet(_cellSplines.Keys); - using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results, overrides); + 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"; } @@ -269,105 +227,6 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable return frame; } - public int? HitTestCell(int imageX, int imageY) - { - return _patternService.FindCellAtPoint(imageX, imageY, BuildCurrentPattern()); - } - - public Task ShowCellHistogramAsync(int imageX, int imageY, Avalonia.Controls.Window owner) - { - using var frame = GetAnalysisFrame(); - if (frame == null) return Task.CompletedTask; - - var pattern = BuildCurrentPattern(); - var cellIndex = _patternService.FindCellAtPoint(imageX, imageY, pattern); - if (cellIndex == null) return Task.CompletedTask; - - if (_averageSpline is not { Length: SplineCurve.KnotCount }) - { - StatusText = "Set reference first"; - return Task.CompletedTask; - } - - try - { - // Already open: commit the previous cell's edits and switch to the new cell. - if (_cellDialog != null && _cellDialogVm != null) - { - CommitDialogStateToCell(_cellDialogVm); - LoadCellIntoDialog(_cellDialogVm, frame, pattern, cellIndex.Value); - _cellDialog.Activate(); - RunTestPattern(); - return Task.CompletedTask; - } - - var dialogVm = new CellHistogramViewModel { Bins = new[] { 5, 5, 5 } }; - LoadCellIntoDialog(dialogVm, frame, pattern, cellIndex.Value); - - // Re-test against the current frame on every spline drag release so the - // calibration window's preview updates live behind the open dialog. - dialogVm.DragCompleted = (avg, cell) => - { - if (avg is { Length: SplineCurve.KnotCount }) - _averageSpline = avg; - if (cell is { Length: SplineCurve.KnotCount }) - _cellSplines[dialogVm.CellIndex] = cell; - else - _cellSplines.Remove(dialogVm.CellIndex); - RunTestPattern(); - }; - - _cellDialogVm = dialogVm; - _cellDialog = new CellHistogramWindow { DataContext = dialogVm }; - _cellDialog.Closed += OnCellDialogClosed; - _cellDialog.Show(owner); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to show cell histogram"); - StatusText = $"Histogram error: {ex.Message}"; - } - return Task.CompletedTask; - } - - private void LoadCellIntoDialog(CellHistogramViewModel vm, Mat frame, CellPattern pattern, int cellIndex) - { - var bins = vm.Bins is { Length: 3 } ? vm.Bins : new[] { 5, 5, 5 }; - var cellHist = _patternService.ComputeCellHistogram(frame, pattern, cellIndex, bins); - var avgSplineCopy = (float[])(_averageSpline ?? new float[SplineCurve.KnotCount]).Clone(); - var cellSplineCopy = _cellSplines.TryGetValue(cellIndex, out var existing) && existing is { Length: SplineCurve.KnotCount } - ? (float[])existing.Clone() - : null; - - vm.CellIndex = cellIndex; - vm.Title = $"Cell #{cellIndex}"; - vm.CellHistogram = cellHist; - vm.AverageSpline = avgSplineCopy; - vm.CellSpline = cellSplineCopy; - } - - private void CommitDialogStateToCell(CellHistogramViewModel vm) - { - if (vm.AverageSpline is { Length: SplineCurve.KnotCount }) - _averageSpline = vm.AverageSpline; - - if (vm.CellSpline is { Length: SplineCurve.KnotCount }) - _cellSplines[vm.CellIndex] = vm.CellSpline; - else - _cellSplines.Remove(vm.CellIndex); - } - - private void OnCellDialogClosed(object? sender, EventArgs e) - { - if (_cellDialogVm != null) - CommitDialogStateToCell(_cellDialogVm); - if (_cellDialog != null) - _cellDialog.Closed -= OnCellDialogClosed; - _cellDialog = null; - _cellDialogVm = null; - RunTestPattern(); - } - [RelayCommand] private void SaveRecipe() { @@ -411,29 +270,15 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable RoiWidth = recipe.Pattern.RoiWidth; RoiHeight = recipe.Pattern.RoiHeight; - _averageReferenceHistogram = null; - _averageSpline = recipe.AverageSpline is { Length: SplineCurve.KnotCount } - ? recipe.AverageSpline - : null; - - _cellSplines.Clear(); - if (recipe.CellSplines != null) - { - foreach (var kv in recipe.CellSplines) - { - if (kv.Value is { Length: SplineCurve.KnotCount }) - _cellSplines[kv.Key] = kv.Value; - } - } - - HasAverageSpline = _averageSpline != null; + AThreshold = recipe.AThreshold; + LThreshold = recipe.LThreshold; + MinBlobArea = recipe.MinBlobArea; + MaxBlobArea = recipe.MaxBlobArea; _referenceFrame?.Dispose(); _referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe); - StatusText = HasAverageSpline - ? $"Loaded recipe '{recipeName}'" - : $"Loaded recipe '{recipeName}' — no spline; click 'Set Reference'."; + StatusText = $"Loaded recipe '{recipeName}'"; } catch (Exception ex) { @@ -457,9 +302,10 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable { RecipeName = CurrentRecipeName, Pattern = BuildCurrentPattern(), - HistogramBins = [5, 5, 5], - AverageSpline = _averageSpline ?? [], - CellSplines = new Dictionary(_cellSplines) + AThreshold = (int)(AThreshold ?? 140m), + LThreshold = (int)(LThreshold ?? 100m), + MinBlobArea = (int)(MinBlobArea ?? 100m), + MaxBlobArea = (int)(MaxBlobArea ?? 5000m) }; private async Task PreviewLoopAsync(CancellationToken ct) @@ -531,11 +377,6 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable owned = true; } _patternService.DrawPattern(displayFrame, pattern, GridColor); - if (_cellSplines.Count > 0) - { - var overrides = new HashSet(_cellSplines.Keys); - _patternService.DrawOverrideMarkers(displayFrame, pattern, overrides); - } } var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame); @@ -563,32 +404,19 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable } } - /// - /// Pattern geometry changed — cell indices are no longer valid, so any per-cell - /// overrides and the seeded average spline must be discarded. The user has to - /// click 'Set Reference' again before testing. - /// - private void InvalidateSplinesForPatternChange() - { - if (_cellSplines.Count == 0 && _averageSpline == null) - return; - - _cellSplines.Clear(); - _averageSpline = null; - _averageReferenceHistogram = null; - HasAverageSpline = false; - StatusText = "Pattern changed — splines cleared. Click 'Set Reference'."; - } - partial void OnApplyCalibrationChanged(bool value) => RefreshPreview(); - partial void OnRowsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } - partial void OnColsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } - partial void OnSelectedCellShapeChanged(CellShape value) { InvalidateSplinesForPatternChange(); RefreshPreview(); } - partial void OnPaddingChanged(decimal? value) { InvalidateSplinesForPatternChange(); 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) { @@ -600,13 +428,6 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable public void Dispose() { _singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged; - if (_cellDialog != null) - { - _cellDialog.Closed -= OnCellDialogClosed; - _cellDialog.Close(); - _cellDialog = null; - _cellDialogVm = null; - } StopPreview(); _lastFrame?.Dispose(); _referenceFrame?.Dispose(); diff --git a/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs b/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs deleted file mode 100644 index 2f22b7b..0000000 --- a/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs +++ /dev/null @@ -1,72 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using LindtLeerformPlugin.Services; - -namespace LindtLeerformPlugin.ViewModels; - -public partial class CellHistogramViewModel : ObservableObject -{ - [ObservableProperty] private string _title = "Cell Histogram"; - public int CellIndex { get; set; } - public int[] Bins { get; init; } = [5, 5, 5]; - - [ObservableProperty] private float[]? _cellHistogram; - [ObservableProperty] private float[]? _cellSpline; - [ObservableProperty] private float[] _averageSpline = new float[SplineCurve.KnotCount]; - [ObservableProperty] private string _verdictText = string.Empty; - - public bool HasOverride => CellSpline is { Length: SplineCurve.KnotCount }; - - /// - /// Invoked by the view when the user releases the mouse after dragging a spline handle. - /// Lets the calibration window re-test and refresh its preview while the dialog stays open. - /// - public Action? DragCompleted { get; set; } - - public void RaiseDragCompleted() => DragCompleted?.Invoke(AverageSpline, CellSpline); - - partial void OnCellSplineChanged(float[]? value) - { - OnPropertyChanged(nameof(HasOverride)); - UpdateVerdict(); - } - - partial void OnAverageSplineChanged(float[] value) => UpdateVerdict(); - partial void OnCellHistogramChanged(float[]? value) => UpdateVerdict(); - - [RelayCommand] - private void CreateOverride() - { - // Seed from THIS cell's histogram so the user gets a useful starting envelope. - CellSpline = SplineCurve.CreateDefault(CellHistogram); - } - - [RelayCommand] - private void RemoveOverride() - { - CellSpline = null; - } - - private void UpdateVerdict() - { - var hist = CellHistogram; - if (hist == null || hist.Length == 0) - { - VerdictText = string.Empty; - return; - } - - var spline = CellSpline is { Length: SplineCurve.KnotCount } ? CellSpline : AverageSpline; - if (spline is not { Length: SplineCurve.KnotCount }) - { - VerdictText = string.Empty; - return; - } - - var (isGood, exceed) = LindtLeerformPlugin.Services.LeerformPatternRecognitionService - .EvaluateAgainstSpline(hist, spline); - var label = isGood ? "GOOD" : "BAD"; - var splineLabel = HasOverride ? "cell spline" : "average spline"; - VerdictText = $"Verdict: {label} Max exceedance: {exceed:F4} Using: {splineLabel}"; - } -} diff --git a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml index 0bff541..34d52d1 100644 --- a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml +++ b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml @@ -102,11 +102,21 @@ -