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">
+
+
+
+
+
+
-
-
-
+
+
+
@@ -29,17 +49,14 @@
-
-
-
+
+
+
-
-
+
+
@@ -85,15 +102,12 @@
-
-
+
+
-
-
-
-
+
@@ -105,8 +119,8 @@
-
+
diff --git a/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml b/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml
index 8e33020..995e15c 100644
--- a/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml
+++ b/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml
@@ -1,26 +1,43 @@
+ WindowStartupLocation="CenterOwner"
+ ShowInTaskbar="False"
+ CanResize="True">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml.cs b/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml.cs
index 11ee2cf..c6d0a1a 100644
--- a/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml.cs
+++ b/Plugins/LindtLeerformPlugin/Views/CellHistogramWindow.axaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using LindtLeerformPlugin.ViewModels;
namespace LindtLeerformPlugin.Views;
@@ -7,5 +8,7 @@ public partial class CellHistogramWindow : Window
public CellHistogramWindow()
{
InitializeComponent();
+ Editor.SplineDragCompleted += (_, _) =>
+ (DataContext as CellHistogramViewModel)?.RaiseDragCompleted();
}
}
diff --git a/Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs b/Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs
new file mode 100644
index 0000000..7b3c689
--- /dev/null
+++ b/Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs
@@ -0,0 +1,305 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using LindtLeerformPlugin.Services;
+
+namespace LindtLeerformPlugin.Views;
+
+///
+/// Custom Avalonia control that draws a histogram as colored bars and overlays an editable
+/// average spline (faint dashed gray) plus an optional editable cell spline (bright orange).
+/// Five control points per spline; X positions are fixed and only Y is dragged.
+///
+public class SplineHistogramEditor : Control
+{
+ public static readonly StyledProperty HistogramProperty =
+ AvaloniaProperty.Register(nameof(Histogram));
+
+ public static readonly StyledProperty CellSplineProperty =
+ AvaloniaProperty.Register(
+ nameof(CellSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
+
+ public static readonly StyledProperty AverageSplineProperty =
+ AvaloniaProperty.Register(
+ nameof(AverageSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
+
+ public static readonly StyledProperty BinsProperty =
+ AvaloniaProperty.Register(nameof(Bins));
+
+ public float[]? Histogram
+ {
+ get => GetValue(HistogramProperty);
+ set => SetValue(HistogramProperty, value);
+ }
+
+ public float[]? CellSpline
+ {
+ get => GetValue(CellSplineProperty);
+ set => SetValue(CellSplineProperty, value);
+ }
+
+ public float[]? AverageSpline
+ {
+ get => GetValue(AverageSplineProperty);
+ set => SetValue(AverageSplineProperty, value);
+ }
+
+ public int[]? Bins
+ {
+ get => GetValue(BinsProperty);
+ set => SetValue(BinsProperty, value);
+ }
+
+ private const double LeftPad = 12;
+ private const double RightPad = 12;
+ private const double TopPad = 12;
+ private const double BottomPad = 18;
+ private const double HitRadius = 12;
+ private const double HandleRadius = 6;
+
+ private static readonly IBrush BackgroundBrush = new SolidColorBrush(Color.FromRgb(20, 20, 20));
+ private static readonly IPen AxisPen = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
+ private static readonly IPen AverageSplinePen = new Pen(
+ new SolidColorBrush(Color.FromRgb(170, 170, 170)), 2,
+ dashStyle: new DashStyle(new double[] { 4, 4 }, 0));
+ private static readonly IBrush AverageHandleFill = new SolidColorBrush(Color.FromRgb(220, 220, 220));
+ private static readonly IPen AverageHandleStroke = new Pen(new SolidColorBrush(Color.FromRgb(80, 80, 80)), 1);
+ private static readonly IPen CellSplinePen = new Pen(new SolidColorBrush(Color.FromRgb(255, 140, 0)), 2.5);
+ private static readonly IBrush CellHandleFill = new SolidColorBrush(Color.FromRgb(255, 140, 0));
+ private static readonly IPen CellHandleStroke = new Pen(Brushes.Black, 1);
+
+ static SplineHistogramEditor()
+ {
+ AffectsRender(
+ HistogramProperty, CellSplineProperty, AverageSplineProperty, BinsProperty);
+ }
+
+ public SplineHistogramEditor()
+ {
+ // Render() fills the bounds, and Control hit-tests its full Bounds rectangle by
+ // default, so we just need to be focusable for pointer capture during drag.
+ Focusable = true;
+ }
+
+ // Cached handle pixel positions, refreshed at every Render(). Indexed [0..4].
+ private readonly Point[] _avgHandlePixels = new Point[SplineCurve.KnotCount];
+ private readonly Point[] _cellHandlePixels = new Point[SplineCurve.KnotCount];
+
+ private SplineKind _draggingKind = SplineKind.None;
+ private int _draggingIndex = -1;
+
+ /// Raised on pointer release after a spline handle drag has completed.
+ public event EventHandler? SplineDragCompleted;
+
+ private enum SplineKind { None, Average, Cell }
+
+ public override void Render(DrawingContext ctx)
+ {
+ var bounds = new Rect(Bounds.Size);
+ ctx.FillRectangle(BackgroundBrush, bounds);
+
+ var plotRect = new Rect(
+ LeftPad, TopPad,
+ Math.Max(1, Bounds.Width - LeftPad - RightPad),
+ Math.Max(1, Bounds.Height - TopPad - BottomPad));
+
+ DrawAxes(ctx, plotRect);
+
+ var hist = Histogram;
+ var bins = Bins;
+ var binCount = (hist?.Length) ?? (bins is { Length: 3 } ? bins[0] * bins[1] * bins[2] : 0);
+
+ // Fixed [0, 1] Y axis — no auto-zoom. Bins and splines are L1-normalised so 1.0
+ // is the natural ceiling and the user always sees an absolute scale.
+ const double displayMax = 1.0;
+
+ if (hist != null && hist.Length > 0 && bins is { Length: 3 })
+ DrawBars(ctx, plotRect, hist, bins, displayMax);
+
+ if (binCount <= 0)
+ binCount = 125; // safe default for spline rendering when histogram is missing
+
+ DrawSpline(ctx, plotRect, AverageSpline, AverageSplinePen, displayMax, binCount,
+ _avgHandlePixels, AverageHandleFill, AverageHandleStroke, drawHandles: AverageSpline is { Length: SplineCurve.KnotCount });
+ DrawSpline(ctx, plotRect, CellSpline, CellSplinePen, displayMax, binCount,
+ _cellHandlePixels, CellHandleFill, CellHandleStroke, drawHandles: CellSpline is { Length: SplineCurve.KnotCount });
+ }
+
+ private static void DrawAxes(DrawingContext ctx, Rect plot)
+ {
+ ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
+ ctx.DrawLine(AxisPen, new Point(plot.Left, plot.Top), new Point(plot.Left, plot.Bottom));
+ }
+
+ private static void DrawBars(DrawingContext ctx, Rect plot, float[] hist, int[] bins, double displayMax)
+ {
+ var totalBins = bins[0] * bins[1] * bins[2];
+ if (totalBins <= 0) return;
+
+ var barWidth = plot.Width / totalBins;
+ for (var i = 0; i < totalBins && i < hist.Length; i++)
+ {
+ var binB = i / (bins[1] * bins[2]);
+ var binG = (i / bins[2]) % bins[1];
+ var binR = i % bins[2];
+
+ var b = (byte)Math.Min(255, (int)((binB + 0.5) * 256 / bins[0]));
+ var g = (byte)Math.Min(255, (int)((binG + 0.5) * 256 / bins[1]));
+ var r = (byte)Math.Min(255, (int)((binR + 0.5) * 256 / bins[2]));
+ var brush = new SolidColorBrush(Color.FromRgb(r, g, b));
+
+ var v = hist[i];
+ if (v < 0) v = 0;
+ var h = v / displayMax * plot.Height;
+ if (h < 0) h = 0;
+ if (h > plot.Height) h = plot.Height;
+
+ var x = plot.Left + i * barWidth;
+ var y = plot.Bottom - h;
+ ctx.FillRectangle(brush, new Rect(x, y, Math.Max(1, barWidth), h));
+ }
+ }
+
+ private void DrawSpline(DrawingContext ctx, Rect plot, float[]? knots, IPen curvePen,
+ double displayMax, int binCount, Point[] handleCache, IBrush handleFill, IPen handleStroke,
+ bool drawHandles)
+ {
+ if (knots == null || knots.Length < 2 || displayMax <= 0)
+ {
+ for (var k = 0; k < handleCache.Length; k++) handleCache[k] = new Point(double.NaN, double.NaN);
+ return;
+ }
+
+ // Sample the spline along the plot width and stroke a polyline.
+ var steps = Math.Max(64, (int)plot.Width);
+ var geometry = new StreamGeometry();
+ using (var sgc = geometry.Open())
+ {
+ for (var s = 0; s <= steps; s++)
+ {
+ var t = (double)s / steps;
+ var y = SplineCurve.Evaluate(knots, t);
+ var px = plot.Left + t * plot.Width;
+ var py = plot.Bottom - Math.Clamp(y / displayMax, 0, 1) * plot.Height;
+ var p = new Point(px, py);
+ if (s == 0) sgc.BeginFigure(p, false);
+ else sgc.LineTo(p);
+ }
+ sgc.EndFigure(false);
+ }
+ ctx.DrawGeometry(null, curvePen, geometry);
+
+ // Cache and draw handles at the knot positions.
+ if (knots.Length <= handleCache.Length)
+ {
+ for (var k = 0; k < knots.Length; k++)
+ {
+ var t = knots.Length == 1 ? 0.0 : (double)k / (knots.Length - 1);
+ var px = plot.Left + t * plot.Width;
+ var py = plot.Bottom - Math.Clamp(knots[k] / displayMax, 0, 1) * plot.Height;
+ handleCache[k] = new Point(px, py);
+
+ if (drawHandles)
+ {
+ ctx.DrawEllipse(handleFill, handleStroke, handleCache[k], HandleRadius, HandleRadius);
+ }
+ }
+ }
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ var props = e.GetCurrentPoint(this).Properties;
+ if (!props.IsLeftButtonPressed) return;
+
+ var pos = e.GetPosition(this);
+
+ // Cell handles take priority over average handles when both overlap.
+ var hit = HitTest(pos, _cellHandlePixels, requireCellSpline: true);
+ if (hit >= 0)
+ {
+ _draggingKind = SplineKind.Cell;
+ _draggingIndex = hit;
+ }
+ else
+ {
+ hit = HitTest(pos, _avgHandlePixels, requireCellSpline: false);
+ if (hit < 0) return;
+ _draggingKind = SplineKind.Average;
+ _draggingIndex = hit;
+ }
+
+ e.Pointer.Capture(this);
+ ApplyDrag(pos);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ if (_draggingKind == SplineKind.None) return;
+ var pos = e.GetPosition(this);
+ ApplyDrag(pos);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (_draggingKind == SplineKind.None) return;
+ _draggingKind = SplineKind.None;
+ _draggingIndex = -1;
+ if (e.Pointer.Captured == this)
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ SplineDragCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ private int HitTest(Point pos, Point[] handles, bool requireCellSpline)
+ {
+ if (requireCellSpline && CellSpline is not { Length: SplineCurve.KnotCount }) return -1;
+
+ for (var i = 0; i < handles.Length; i++)
+ {
+ var h = handles[i];
+ if (double.IsNaN(h.X)) continue;
+ var dx = pos.X - h.X;
+ var dy = pos.Y - h.Y;
+ if (dx * dx + dy * dy <= HitRadius * HitRadius)
+ return i;
+ }
+ return -1;
+ }
+
+ private void ApplyDrag(Point pos)
+ {
+ if (_draggingIndex < 0) return;
+
+ var plotRect = new Rect(
+ LeftPad, TopPad,
+ Math.Max(1, Bounds.Width - LeftPad - RightPad),
+ Math.Max(1, Bounds.Height - TopPad - BottomPad));
+
+ var rel = (plotRect.Bottom - pos.Y) / plotRect.Height;
+ var newY = (float)Math.Clamp(rel, 0.0, 1.0);
+
+ if (_draggingKind == SplineKind.Average)
+ {
+ var current = AverageSpline;
+ if (current is not { Length: SplineCurve.KnotCount }) return;
+ var copy = (float[])current.Clone();
+ copy[_draggingIndex] = newY;
+ SetCurrentValue(AverageSplineProperty, copy);
+ }
+ else if (_draggingKind == SplineKind.Cell)
+ {
+ var current = CellSpline;
+ if (current is not { Length: SplineCurve.KnotCount }) return;
+ var copy = (float[])current.Clone();
+ copy[_draggingIndex] = newY;
+ SetCurrentValue(CellSplineProperty, copy);
+ }
+ }
+}