diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 53fc8b0..7996445 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(find:*)", "Bash(ls:*)", "Bash(dotnet sln:*)", - "Bash(dotnet build:*)" + "Bash(dotnet build:*)", + "Bash(python3)" ] } } diff --git a/Plugins/LindtLeerformPlugin/Models/CellResult.cs b/Plugins/LindtLeerformPlugin/Models/CellResult.cs index f4da47b..68cd321 100644 --- a/Plugins/LindtLeerformPlugin/Models/CellResult.cs +++ b/Plugins/LindtLeerformPlugin/Models/CellResult.cs @@ -4,5 +4,6 @@ public class CellResult { public int Index { get; set; } public bool IsGood { get; set; } - public double Distance { 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; } } diff --git a/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs index 0b2c709..e0b382b 100644 --- a/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs +++ b/Plugins/LindtLeerformPlugin/Models/LeerformRecipe.cs @@ -5,7 +5,7 @@ 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[] ReferenceHistogram { get; set; } = []; - public double Tolerance { get; set; } = 0.30; + public float[] AverageSpline { get; set; } = []; + public Dictionary CellSplines { get; set; } = new(); public string ThumbnailBase64 { get; set; } = string.Empty; } diff --git a/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs index 20a6751..5ad92d0 100644 --- a/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs +++ b/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs @@ -5,8 +5,9 @@ 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 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 public IReadOnlyList ComputeCells(CellPattern pattern) { @@ -73,6 +74,11 @@ public class LeerformPatternRecognitionService return mask; } + /// + /// 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. + /// public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null) { bins ??= [5, 5, 5]; @@ -88,29 +94,67 @@ public class LeerformPatternRecognitionService var imageSize = new Size(bgrImage.Cols, bgrImage.Rows); var results = new List(cells.Count); - using var refHist = ArrayToHistogram(recipe.ReferenceHistogram, bins); - 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 }) + { + spline = custom; + } + else if (recipe.AverageSpline is { Length: SplineCurve.KnotCount }) + { + spline = recipe.AverageSpline; + } + + 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 distance = Cv2.CompareHist(refHist, hist, HistCompMethods.Bhattacharyya); + var arr = HistogramToArray(hist); + + var (isGood, exceed) = EvaluateAgainstSpline(arr, spline); results.Add(new CellResult { Index = cell.Index, - IsGood = distance <= recipe.Tolerance, - Distance = distance + IsGood = isGood, + MaxExceedance = exceed }); } return results; } - public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList 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) { 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) @@ -121,13 +165,12 @@ public class LeerformPatternRecognitionService 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); - } + + if (overriddenCells != null && overriddenCells.Contains(cell.Index)) + DrawOverrideMarker(overlay, cell, pattern.Shape, imageRect); } return overlay; } @@ -144,6 +187,39 @@ 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); @@ -173,14 +249,6 @@ public class LeerformPatternRecognitionService return HistogramToArray(hist); } - public double CompareHistograms(float[] reference, float[] candidate, int[]? bins = null) - { - bins ??= [5, 5, 5]; - using var refMat = ArrayToHistogram(reference, bins); - using var candMat = ArrayToHistogram(candidate, bins); - return Cv2.CompareHist(refMat, candMat, HistCompMethods.Bhattacharyya); - } - 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)); @@ -229,7 +297,8 @@ public class LeerformPatternRecognitionService dims: 3, histSize: bins, ranges: new[] { new Rangef(0, 256), new Rangef(0, 256), new Rangef(0, 256) }); - Cv2.Normalize(hist, hist); + // 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(); @@ -245,14 +314,4 @@ public class LeerformPatternRecognitionService arr[i] = hist.At(i, 0); return arr; } - - private static Mat ArrayToHistogram(float[] data, int[] bins) - { - var totalBins = bins[0] * bins[1] * bins[2]; - var mat = new Mat(totalBins, 1, MatType.CV_32F, Scalar.All(0)); - var count = Math.Min(data.Length, totalBins); - for (var i = 0; i < count; i++) - mat.Set(i, 0, data[i]); - return mat; - } } diff --git a/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs b/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs new file mode 100644 index 0000000..9a0debf --- /dev/null +++ b/Plugins/LindtLeerformPlugin/Services/SplineCurve.cs @@ -0,0 +1,116 @@ +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 20032e5..0467274 100644 --- a/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs +++ b/Plugins/LindtLeerformPlugin/ViewModels/CalibrationWindowViewModel.cs @@ -25,7 +25,16 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable private Mat? _calibCameraMatrix; private Mat? _calibDistCoeffs; private Mat? _referenceFrame; - private float[]? _referenceHistogram; + + // 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"; @@ -49,8 +58,7 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable [ObservableProperty] private decimal? _roiHeight = 0m; // Detection - [ObservableProperty] private decimal? _tolerance = 0.30m; - [ObservableProperty] private bool _hasReferenceHistogram; + [ObservableProperty] private bool _hasAverageSpline; // Recipe [ObservableProperty] private string _currentRecipeName = "default"; @@ -190,23 +198,28 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable try { var pattern = BuildCurrentPattern(); - _referenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); + _averageReferenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); + _averageSpline = SplineCurve.CreateDefault(_averageReferenceHistogram); + _cellSplines.Clear(); _referenceFrame?.Dispose(); _referenceFrame = frame.Clone(); - HasReferenceHistogram = true; - StatusText = $"Reference set ({_referenceHistogram.Length} bins)"; + HasAverageSpline = true; + StatusText = "Average spline seeded; per-cell overrides cleared."; + RefreshPreview(); } catch (Exception ex) { - Log.Error(ex, "Failed to compute reference histogram"); + Log.Error(ex, "Failed to seed average spline"); StatusText = $"Reference error: {ex.Message}"; } } [RelayCommand] - private void TestPattern() + private void TestPattern() => RunTestPattern(); + + private void RunTestPattern() { using var frame = GetAnalysisFrame(); if (frame == null) @@ -214,9 +227,9 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable StatusText = "No active frame to test"; return; } - if (_referenceHistogram == null) + if (_averageSpline == null) { - StatusText = "Set reference histogram first"; + StatusText = "Set reference first"; return; } @@ -224,7 +237,8 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable { var recipe = BuildCurrentRecipe(); var results = _patternService.EvaluateCells(frame, recipe); - using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results); + var overrides = new HashSet(_cellSplines.Keys); + using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results, overrides); var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay); var old = PreviewImage; @@ -260,51 +274,98 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable return _patternService.FindCellAtPoint(imageX, imageY, BuildCurrentPattern()); } - public async Task ShowCellHistogramAsync(int imageX, int imageY, Avalonia.Controls.Window owner) + public Task ShowCellHistogramAsync(int imageX, int imageY, Avalonia.Controls.Window owner) { using var frame = GetAnalysisFrame(); - if (frame == null) return; + if (frame == null) return Task.CompletedTask; var pattern = BuildCurrentPattern(); var cellIndex = _patternService.FindCellAtPoint(imageX, imageY, pattern); - if (cellIndex == null) return; + if (cellIndex == null) return Task.CompletedTask; - if (_referenceHistogram == null) + if (_averageSpline is not { Length: SplineCurve.KnotCount }) { - StatusText = "Set reference histogram first"; - return; + StatusText = "Set reference first"; + return Task.CompletedTask; } try { - var bins = new[] { 5, 5, 5 }; - var cellHist = _patternService.ComputeCellHistogram(frame, pattern, cellIndex.Value, bins); - var distance = _patternService.CompareHistograms(_referenceHistogram, cellHist, bins); - - using var refChart = LeerformPatternRecognitionService.RenderHistogramChart(_referenceHistogram, bins); - using var cellChart = LeerformPatternRecognitionService.RenderHistogramChart(cellHist, bins); - - var refBitmap = ImageConverter.MatToAvaloniaBitmap(refChart); - var cellBitmap = ImageConverter.MatToAvaloniaBitmap(cellChart); - - var tolerance = (double)(Tolerance ?? 0.30m); - var verdict = distance <= tolerance ? "GOOD" : "BAD"; - var dialogVm = new CellHistogramViewModel + // Already open: commit the previous cell's edits and switch to the new cell. + if (_cellDialog != null && _cellDialogVm != null) { - Title = $"Cell #{cellIndex.Value}", - ReferenceImage = refBitmap, - CellImage = cellBitmap, - DistanceText = $"Distance: {distance:F4} Tolerance: {tolerance:F2} Verdict: {verdict}" + 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(); }; - var window = new CellHistogramWindow { DataContext = dialogVm }; - await window.ShowDialog(owner); + _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] @@ -349,17 +410,30 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable RoiY = recipe.Pattern.RoiY; RoiWidth = recipe.Pattern.RoiWidth; RoiHeight = recipe.Pattern.RoiHeight; - Tolerance = (decimal)recipe.Tolerance; - _referenceHistogram = recipe.ReferenceHistogram is { Length: > 0 } - ? recipe.ReferenceHistogram + _averageReferenceHistogram = null; + _averageSpline = recipe.AverageSpline is { Length: SplineCurve.KnotCount } + ? recipe.AverageSpline : null; - HasReferenceHistogram = _referenceHistogram != 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; _referenceFrame?.Dispose(); _referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe); - StatusText = $"Loaded recipe '{recipeName}'"; + StatusText = HasAverageSpline + ? $"Loaded recipe '{recipeName}'" + : $"Loaded recipe '{recipeName}' — no spline; click 'Set Reference'."; } catch (Exception ex) { @@ -384,8 +458,8 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable RecipeName = CurrentRecipeName, Pattern = BuildCurrentPattern(), HistogramBins = [5, 5, 5], - ReferenceHistogram = _referenceHistogram ?? [], - Tolerance = (double)(Tolerance ?? 0.30m) + AverageSpline = _averageSpline ?? [], + CellSplines = new Dictionary(_cellSplines) }; private async Task PreviewLoopAsync(CancellationToken ct) @@ -457,6 +531,11 @@ 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); @@ -484,11 +563,28 @@ 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) => RefreshPreview(); - partial void OnColsChanged(decimal? value) => RefreshPreview(); - partial void OnSelectedCellShapeChanged(CellShape value) => RefreshPreview(); - partial void OnPaddingChanged(decimal? 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 OnRoiXChanged(decimal? value) => RefreshPreview(); partial void OnRoiYChanged(decimal? value) => RefreshPreview(); partial void OnRoiWidthChanged(decimal? value) => RefreshPreview(); @@ -504,6 +600,13 @@ 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 index e839db0..2f22b7b 100644 --- a/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs +++ b/Plugins/LindtLeerformPlugin/ViewModels/CellHistogramViewModel.cs @@ -1,11 +1,72 @@ -using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LindtLeerformPlugin.Services; namespace LindtLeerformPlugin.ViewModels; -public class CellHistogramViewModel +public partial class CellHistogramViewModel : ObservableObject { - public string Title { get; set; } = "Cell Histogram"; - public Bitmap? ReferenceImage { get; set; } - public Bitmap? CellImage { get; set; } - public string DistanceText { get; set; } = string.Empty; + [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 dec82a4..0bff541 100644 --- a/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml +++ b/Plugins/LindtLeerformPlugin/Views/CalibrationWindow.axaml @@ -6,12 +6,32 @@ Title="Leerform Calibration" Width="1024" Height="800"> + + + + + + -