histogram implemented

This commit is contained in:
EugeneTes
2026-04-08 13:10:34 +02:00
parent 931de47164
commit 2be13501fa
11 changed files with 805 additions and 125 deletions

View File

@@ -4,7 +4,8 @@
"Bash(find:*)", "Bash(find:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(dotnet sln:*)", "Bash(dotnet sln:*)",
"Bash(dotnet build:*)" "Bash(dotnet build:*)",
"Bash(python3)"
] ]
} }
} }

View File

@@ -4,5 +4,6 @@ public class CellResult
{ {
public int Index { get; set; } public int Index { get; set; }
public bool IsGood { get; set; } public bool IsGood { get; set; }
public double Distance { get; set; } /// <summary>Largest (histogram bin value spline at that bin) across all bins. Negative if every bin is below the spline.</summary>
public double MaxExceedance { get; set; }
} }

View File

@@ -5,7 +5,7 @@ public class LeerformRecipe
public string RecipeName { get; set; } = string.Empty; public string RecipeName { get; set; } = string.Empty;
public CellPattern Pattern { get; set; } = new(); public CellPattern Pattern { get; set; } = new();
public int[] HistogramBins { get; set; } = [5, 5, 5]; public int[] HistogramBins { get; set; } = [5, 5, 5];
public float[] ReferenceHistogram { get; set; } = []; public float[] AverageSpline { get; set; } = [];
public double Tolerance { get; set; } = 0.30; public Dictionary<int, float[]> CellSplines { get; set; } = new();
public string ThumbnailBase64 { get; set; } = string.Empty; public string ThumbnailBase64 { get; set; } = string.Empty;
} }

View File

@@ -5,8 +5,9 @@ namespace LindtLeerformPlugin.Services;
public class LeerformPatternRecognitionService public class LeerformPatternRecognitionService
{ {
private static readonly Scalar GoodColor = new(0, 255, 0); // BGR Green 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 BadColor = new(0, 0, 255); // BGR Red
private static readonly Scalar OverrideColor = new(0, 255, 255); // BGR Yellow
public IReadOnlyList<CellRegion> ComputeCells(CellPattern pattern) public IReadOnlyList<CellRegion> ComputeCells(CellPattern pattern)
{ {
@@ -73,6 +74,11 @@ public class LeerformPatternRecognitionService
return mask; return mask;
} }
/// <summary>
/// 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.
/// </summary>
public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null) public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null)
{ {
bins ??= [5, 5, 5]; bins ??= [5, 5, 5];
@@ -88,29 +94,67 @@ public class LeerformPatternRecognitionService
var imageSize = new Size(bgrImage.Cols, bgrImage.Rows); var imageSize = new Size(bgrImage.Cols, bgrImage.Rows);
var results = new List<CellResult>(cells.Count); var results = new List<CellResult>(cells.Count);
using var refHist = ArrayToHistogram(recipe.ReferenceHistogram, bins);
foreach (var cell in cells) 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 mask = BuildMask(recipe.Pattern, imageSize, cell.Index);
using var hist = CalcHistogram(bgrImage, mask, bins); 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 results.Add(new CellResult
{ {
Index = cell.Index, Index = cell.Index,
IsGood = distance <= recipe.Tolerance, IsGood = isGood,
Distance = distance MaxExceedance = exceed
}); });
} }
return results; return results;
} }
public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList<CellResult> results) /// <summary>
/// Cell is good when no histogram bin rises above the spline. <c>maxExceedance</c> is the
/// largest (bin spline) across all bins; non-positive means the cell passes.
/// </summary>
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<CellResult> results,
IReadOnlySet<int>? overriddenCells = null)
{ {
var overlay = bgrImage.Clone(); var overlay = bgrImage.Clone();
var cells = ComputeCells(pattern); var cells = ComputeCells(pattern);
var resultByIndex = results.ToDictionary(r => r.Index); var resultByIndex = results.ToDictionary(r => r.Index);
var imageRect = new Rect(0, 0, overlay.Cols, overlay.Rows);
const int thickness = 2; const int thickness = 2;
foreach (var cell in cells) foreach (var cell in cells)
@@ -121,13 +165,12 @@ public class LeerformPatternRecognitionService
var color = result.IsGood ? GoodColor : BadColor; var color = result.IsGood ? GoodColor : BadColor;
if (pattern.Shape == CellShape.Square) if (pattern.Shape == CellShape.Square)
{
Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness); Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness);
}
else else
{
Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness); Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness);
}
if (overriddenCells != null && overriddenCells.Contains(cell.Index))
DrawOverrideMarker(overlay, cell, pattern.Shape, imageRect);
} }
return overlay; return overlay;
} }
@@ -144,6 +187,39 @@ public class LeerformPatternRecognitionService
} }
} }
public void DrawOverrideMarkers(Mat target, CellPattern pattern, IReadOnlySet<int> 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) public int? FindCellAtPoint(int x, int y, CellPattern pattern)
{ {
var cells = ComputeCells(pattern); var cells = ComputeCells(pattern);
@@ -173,14 +249,6 @@ public class LeerformPatternRecognitionService
return HistogramToArray(hist); 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) 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)); var chart = new Mat(height, width, MatType.CV_8UC3, new Scalar(30, 30, 30));
@@ -229,7 +297,8 @@ public class LeerformPatternRecognitionService
dims: 3, dims: 3,
histSize: bins, histSize: bins,
ranges: new[] { new Rangef(0, 256), new Rangef(0, 256), new Rangef(0, 256) }); 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 totalBins = bins[0] * bins[1] * bins[2];
var reshaped = hist.Reshape(1, totalBins).Clone(); var reshaped = hist.Reshape(1, totalBins).Clone();
@@ -245,14 +314,4 @@ public class LeerformPatternRecognitionService
arr[i] = hist.At<float>(i, 0); arr[i] = hist.At<float>(i, 0);
return arr; 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<float>(i, 0, data[i]);
return mat;
}
} }

View File

@@ -0,0 +1,116 @@
namespace LindtLeerformPlugin.Services;
/// <summary>
/// Monotone cubic Hermite spline (FritschCarlson). 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.
/// </summary>
public static class SplineCurve
{
public const int KnotCount = 5;
/// <summary>
/// Evaluate the spline at parameter <paramref name="t"/> in [0, 1].
/// Knots are positioned at t = i / (knots.Length - 1).
/// </summary>
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;
}
/// <summary>
/// Convenience: evaluate the spline at the position of bin <paramref name="binIndex"/>
/// of a histogram with <paramref name="totalBins"/> bins.
/// </summary>
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);
}
/// <summary>
/// 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].
/// </summary>
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;
}
}

View File

@@ -25,7 +25,16 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
private Mat? _calibCameraMatrix; private Mat? _calibCameraMatrix;
private Mat? _calibDistCoeffs; private Mat? _calibDistCoeffs;
private Mat? _referenceFrame; 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<int, float[]> _cellSplines = new();
[ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage; [ObservableProperty] private Avalonia.Media.Imaging.Bitmap? _previewImage;
[ObservableProperty] private string _statusText = "Ready"; [ObservableProperty] private string _statusText = "Ready";
@@ -49,8 +58,7 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
[ObservableProperty] private decimal? _roiHeight = 0m; [ObservableProperty] private decimal? _roiHeight = 0m;
// Detection // Detection
[ObservableProperty] private decimal? _tolerance = 0.30m; [ObservableProperty] private bool _hasAverageSpline;
[ObservableProperty] private bool _hasReferenceHistogram;
// Recipe // Recipe
[ObservableProperty] private string _currentRecipeName = "default"; [ObservableProperty] private string _currentRecipeName = "default";
@@ -190,23 +198,28 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
try try
{ {
var pattern = BuildCurrentPattern(); var pattern = BuildCurrentPattern();
_referenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern); _averageReferenceHistogram = _patternService.ComputeReferenceHistogram(frame, pattern);
_averageSpline = SplineCurve.CreateDefault(_averageReferenceHistogram);
_cellSplines.Clear();
_referenceFrame?.Dispose(); _referenceFrame?.Dispose();
_referenceFrame = frame.Clone(); _referenceFrame = frame.Clone();
HasReferenceHistogram = true; HasAverageSpline = true;
StatusText = $"Reference set ({_referenceHistogram.Length} bins)"; StatusText = "Average spline seeded; per-cell overrides cleared.";
RefreshPreview();
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Failed to compute reference histogram"); Log.Error(ex, "Failed to seed average spline");
StatusText = $"Reference error: {ex.Message}"; StatusText = $"Reference error: {ex.Message}";
} }
} }
[RelayCommand] [RelayCommand]
private void TestPattern() private void TestPattern() => RunTestPattern();
private void RunTestPattern()
{ {
using var frame = GetAnalysisFrame(); using var frame = GetAnalysisFrame();
if (frame == null) if (frame == null)
@@ -214,9 +227,9 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
StatusText = "No active frame to test"; StatusText = "No active frame to test";
return; return;
} }
if (_referenceHistogram == null) if (_averageSpline == null)
{ {
StatusText = "Set reference histogram first"; StatusText = "Set reference first";
return; return;
} }
@@ -224,7 +237,8 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
{ {
var recipe = BuildCurrentRecipe(); var recipe = BuildCurrentRecipe();
var results = _patternService.EvaluateCells(frame, recipe); var results = _patternService.EvaluateCells(frame, recipe);
using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results); var overrides = new HashSet<int>(_cellSplines.Keys);
using var overlay = _patternService.RenderOverlay(frame, recipe.Pattern, results, overrides);
var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay); var bitmap = ImageConverter.MatToAvaloniaBitmap(overlay);
var old = PreviewImage; var old = PreviewImage;
@@ -260,51 +274,98 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
return _patternService.FindCellAtPoint(imageX, imageY, BuildCurrentPattern()); 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(); using var frame = GetAnalysisFrame();
if (frame == null) return; if (frame == null) return Task.CompletedTask;
var pattern = BuildCurrentPattern(); var pattern = BuildCurrentPattern();
var cellIndex = _patternService.FindCellAtPoint(imageX, imageY, pattern); 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"; StatusText = "Set reference first";
return; return Task.CompletedTask;
} }
try try
{ {
var bins = new[] { 5, 5, 5 }; // Already open: commit the previous cell's edits and switch to the new cell.
var cellHist = _patternService.ComputeCellHistogram(frame, pattern, cellIndex.Value, bins); if (_cellDialog != null && _cellDialogVm != null)
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
{ {
Title = $"Cell #{cellIndex.Value}", CommitDialogStateToCell(_cellDialogVm);
ReferenceImage = refBitmap, LoadCellIntoDialog(_cellDialogVm, frame, pattern, cellIndex.Value);
CellImage = cellBitmap, _cellDialog.Activate();
DistanceText = $"Distance: {distance:F4} Tolerance: {tolerance:F2} Verdict: {verdict}" 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 }; _cellDialogVm = dialogVm;
await window.ShowDialog(owner); _cellDialog = new CellHistogramWindow { DataContext = dialogVm };
_cellDialog.Closed += OnCellDialogClosed;
_cellDialog.Show(owner);
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Failed to show cell histogram"); Log.Error(ex, "Failed to show cell histogram");
StatusText = $"Histogram error: {ex.Message}"; 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] [RelayCommand]
@@ -349,17 +410,30 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
RoiY = recipe.Pattern.RoiY; RoiY = recipe.Pattern.RoiY;
RoiWidth = recipe.Pattern.RoiWidth; RoiWidth = recipe.Pattern.RoiWidth;
RoiHeight = recipe.Pattern.RoiHeight; RoiHeight = recipe.Pattern.RoiHeight;
Tolerance = (decimal)recipe.Tolerance;
_referenceHistogram = recipe.ReferenceHistogram is { Length: > 0 } _averageReferenceHistogram = null;
? recipe.ReferenceHistogram _averageSpline = recipe.AverageSpline is { Length: SplineCurve.KnotCount }
? recipe.AverageSpline
: null; : 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?.Dispose();
_referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe); _referenceFrame = LeerformRecipeStore.DecodeThumbnail(recipe);
StatusText = $"Loaded recipe '{recipeName}'"; StatusText = HasAverageSpline
? $"Loaded recipe '{recipeName}'"
: $"Loaded recipe '{recipeName}' — no spline; click 'Set Reference'.";
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -384,8 +458,8 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
RecipeName = CurrentRecipeName, RecipeName = CurrentRecipeName,
Pattern = BuildCurrentPattern(), Pattern = BuildCurrentPattern(),
HistogramBins = [5, 5, 5], HistogramBins = [5, 5, 5],
ReferenceHistogram = _referenceHistogram ?? [], AverageSpline = _averageSpline ?? [],
Tolerance = (double)(Tolerance ?? 0.30m) CellSplines = new Dictionary<int, float[]>(_cellSplines)
}; };
private async Task PreviewLoopAsync(CancellationToken ct) private async Task PreviewLoopAsync(CancellationToken ct)
@@ -457,6 +531,11 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
owned = true; owned = true;
} }
_patternService.DrawPattern(displayFrame, pattern, GridColor); _patternService.DrawPattern(displayFrame, pattern, GridColor);
if (_cellSplines.Count > 0)
{
var overrides = new HashSet<int>(_cellSplines.Keys);
_patternService.DrawOverrideMarkers(displayFrame, pattern, overrides);
}
} }
var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame); var bitmap = ImageConverter.MatToAvaloniaBitmap(displayFrame);
@@ -484,11 +563,28 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
} }
} }
/// <summary>
/// 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.
/// </summary>
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 OnApplyCalibrationChanged(bool value) => RefreshPreview();
partial void OnRowsChanged(decimal? value) => RefreshPreview(); partial void OnRowsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); }
partial void OnColsChanged(decimal? value) => RefreshPreview(); partial void OnColsChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); }
partial void OnSelectedCellShapeChanged(CellShape value) => RefreshPreview(); partial void OnSelectedCellShapeChanged(CellShape value) { InvalidateSplinesForPatternChange(); RefreshPreview(); }
partial void OnPaddingChanged(decimal? value) => RefreshPreview(); partial void OnPaddingChanged(decimal? value) { InvalidateSplinesForPatternChange(); RefreshPreview(); }
partial void OnRoiXChanged(decimal? value) => RefreshPreview(); partial void OnRoiXChanged(decimal? value) => RefreshPreview();
partial void OnRoiYChanged(decimal? value) => RefreshPreview(); partial void OnRoiYChanged(decimal? value) => RefreshPreview();
partial void OnRoiWidthChanged(decimal? value) => RefreshPreview(); partial void OnRoiWidthChanged(decimal? value) => RefreshPreview();
@@ -504,6 +600,13 @@ public partial class CalibrationWindowViewModel : ObservableObject, IDisposable
public void Dispose() public void Dispose()
{ {
_singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged; _singleCameraVm.PropertyChanged -= OnSingleCameraVmPropertyChanged;
if (_cellDialog != null)
{
_cellDialog.Closed -= OnCellDialogClosed;
_cellDialog.Close();
_cellDialog = null;
_cellDialogVm = null;
}
StopPreview(); StopPreview();
_lastFrame?.Dispose(); _lastFrame?.Dispose();
_referenceFrame?.Dispose(); _referenceFrame?.Dispose();

View File

@@ -1,11 +1,72 @@
using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LindtLeerformPlugin.Services;
namespace LindtLeerformPlugin.ViewModels; namespace LindtLeerformPlugin.ViewModels;
public class CellHistogramViewModel public partial class CellHistogramViewModel : ObservableObject
{ {
public string Title { get; set; } = "Cell Histogram"; [ObservableProperty] private string _title = "Cell Histogram";
public Bitmap? ReferenceImage { get; set; } public int CellIndex { get; set; }
public Bitmap? CellImage { get; set; } public int[] Bins { get; init; } = [5, 5, 5];
public string DistanceText { get; set; } = string.Empty;
[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 };
/// <summary>
/// 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.
/// </summary>
public Action<float[], float[]?>? 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}";
}
} }

View File

@@ -6,12 +6,32 @@
Title="Leerform Calibration" Title="Leerform Calibration"
Width="1024" Height="800"> Width="1024" Height="800">
<Window.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="Red" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#FFEBEE" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
<Style Selector="Button:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#FFCDD2" />
<Setter Property="BorderBrush" Value="Red" />
<Setter Property="TextBlock.Foreground" Value="Red" />
</Style>
</Window.Styles>
<DockPanel> <DockPanel>
<!-- Toolbar --> <!-- Toolbar -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="8">
<Button Content="Start Preview" Command="{Binding StartPreviewCommand}" IsEnabled="{Binding !IsPreviewRunning}" /> <Button Content="START PREVIEW" Command="{Binding StartPreviewCommand}" IsEnabled="{Binding !IsPreviewRunning}" />
<Button Content="Stop Preview" Command="{Binding StopPreviewCommand}" IsEnabled="{Binding IsPreviewRunning}" /> <Button Content="STOP PREVIEW" Command="{Binding StopPreviewCommand}" IsEnabled="{Binding IsPreviewRunning}" />
<Button Content="Capture Image" Command="{Binding CaptureImageCommand}" /> <Button Content="CAPTURE IMAGE" Command="{Binding CaptureImageCommand}" />
</StackPanel> </StackPanel>
<!-- Status Bar --> <!-- Status Bar -->
@@ -29,17 +49,14 @@
</Border> </Border>
<!-- Settings Panel --> <!-- Settings Panel -->
<Border DockPanel.Dock="Right" Width="260" Padding="12" Background="White"> <Border DockPanel.Dock="Right" Width="290" Padding="12" Background="White">
<ScrollViewer> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="8"> <StackPanel Spacing="8" Margin="0,0,20,0">
<TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" /> <TextBlock Text="Calibration Settings" FontWeight="Bold" FontSize="14" Foreground="Black" />
<Button Content="Calibrate" Command="{Binding RunCalibrationCommand}" Margin="0,4" <Button Content="CALIBRATE" Command="{Binding RunCalibrationCommand}" Margin="0,4" />
Background="Red" Foreground="White" FontWeight="Bold" /> <Button Content="CLEAR IMAGES" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4" />
<Button Content="Clear Images" Command="{Binding ClearCalibrationImagesCommand}" Margin="0,4"
Background="White" Foreground="Red" FontWeight="Bold"
BorderBrush="Red" BorderThickness="1" />
<Separator Margin="0,8" /> <Separator Margin="0,8" />
@@ -85,15 +102,12 @@
<Separator Margin="0,8" /> <Separator Margin="0,8" />
<Button Content="Set Reference" Command="{Binding SetReferenceHistogramCommand}" Margin="0,4" <Button Content="SET REFERENCE (RESETS SPLINES)" Command="{Binding SetReferenceHistogramCommand}" Margin="0,4"
Background="#2E7D32" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch" />
<TextBlock Text="✓ Reference set" Foreground="LimeGreen" IsVisible="{Binding HasReferenceHistogram}" /> <TextBlock Text="✓ Average spline set" Foreground="LimeGreen" IsVisible="{Binding HasAverageSpline}" />
<TextBlock Text="Tolerance" Foreground="Black" Margin="0,4,0,0" /> <Button Content="TEST PATTERN" Command="{Binding TestPatternCommand}" Margin="0,4"
<NumericUpDown Value="{Binding Tolerance}" Minimum="0" Maximum="1" Increment="0.05" FormatString="0.00" /> HorizontalAlignment="Stretch" />
<Button Content="Test Pattern" Command="{Binding TestPatternCommand}" Margin="0,4"
Background="#1565C0" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" />
<Separator Margin="0,8" /> <Separator Margin="0,8" />
@@ -105,8 +119,8 @@
</MultiBinding> </MultiBinding>
</TextBlock.Text> </TextBlock.Text>
</TextBlock> </TextBlock>
<Button Content="Save Recipe" Command="{Binding SaveRecipeCommand}" Margin="0,4" <Button Content="SAVE RECIPE" Command="{Binding SaveRecipeCommand}" Margin="0,4"
Background="#6A1B9A" Foreground="White" FontWeight="Bold" HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Border> </Border>

View File

@@ -1,26 +1,43 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LindtLeerformPlugin.ViewModels" xmlns:vm="using:LindtLeerformPlugin.ViewModels"
xmlns:v="using:LindtLeerformPlugin.Views"
x:Class="LindtLeerformPlugin.Views.CellHistogramWindow" x:Class="LindtLeerformPlugin.Views.CellHistogramWindow"
x:DataType="vm:CellHistogramViewModel" x:DataType="vm:CellHistogramViewModel"
Title="{Binding Title}" Title="{Binding Title}"
Width="720" Height="600" Width="820" Height="560"
Background="#1A1A1A" Background="#1A1A1A"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
CanResize="True">
<ScrollViewer> <DockPanel Margin="12">
<StackPanel Spacing="12" Margin="16"> <StackPanel DockPanel.Dock="Top" Spacing="4" Margin="0,0,0,8">
<TextBlock Text="Reference (learned)" FontWeight="Bold" Foreground="White" FontSize="14" /> <TextBlock Text="Drag the gray dashed handles to edit the AVERAGE spline. Use 'Override Cell' to give this cell its own spline."
<Border Background="#0A0A0A" Padding="4"> Foreground="LightGray" FontSize="12" TextWrapping="Wrap" />
<Image Source="{Binding ReferenceImage}" Stretch="Uniform" Height="220" /> <TextBlock Text="A cell is BAD when any histogram bar rises above its active spline."
</Border> Foreground="#888888" FontSize="11" TextWrapping="Wrap" />
<TextBlock Text="Selected cell" FontWeight="Bold" Foreground="White" FontSize="14" Margin="0,8,0,0" />
<Border Background="#0A0A0A" Padding="4">
<Image Source="{Binding CellImage}" Stretch="Uniform" Height="220" />
</Border>
<TextBlock Text="{Binding DistanceText}" Foreground="LightGray" FontSize="13" Margin="0,8,0,0" />
</StackPanel> </StackPanel>
</ScrollViewer>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8" Margin="0,12,0,0">
<Button Content="Override Cell"
Command="{Binding CreateOverrideCommand}"
IsVisible="{Binding !HasOverride}"
Background="#FF8C00" Foreground="Black" FontWeight="Bold" />
<Button Content="Remove Override"
Command="{Binding RemoveOverrideCommand}"
IsVisible="{Binding HasOverride}"
Background="#8B0000" Foreground="White" FontWeight="Bold" />
<TextBlock Text="{Binding VerdictText}" Foreground="LightGray"
VerticalAlignment="Center" FontSize="13" />
</StackPanel>
<Border Background="#0A0A0A" Padding="2">
<v:SplineHistogramEditor x:Name="Editor"
Histogram="{Binding CellHistogram}"
CellSpline="{Binding CellSpline, Mode=TwoWay}"
AverageSpline="{Binding AverageSpline, Mode=TwoWay}"
Bins="{Binding Bins}" />
</Border>
</DockPanel>
</Window> </Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using LindtLeerformPlugin.ViewModels;
namespace LindtLeerformPlugin.Views; namespace LindtLeerformPlugin.Views;
@@ -7,5 +8,7 @@ public partial class CellHistogramWindow : Window
public CellHistogramWindow() public CellHistogramWindow()
{ {
InitializeComponent(); InitializeComponent();
Editor.SplineDragCompleted += (_, _) =>
(DataContext as CellHistogramViewModel)?.RaiseDragCompleted();
} }
} }

View File

@@ -0,0 +1,305 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using LindtLeerformPlugin.Services;
namespace LindtLeerformPlugin.Views;
/// <summary>
/// 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.
/// </summary>
public class SplineHistogramEditor : Control
{
public static readonly StyledProperty<float[]?> HistogramProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(nameof(Histogram));
public static readonly StyledProperty<float[]?> CellSplineProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
nameof(CellSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<float[]?> AverageSplineProperty =
AvaloniaProperty.Register<SplineHistogramEditor, float[]?>(
nameof(AverageSpline), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public static readonly StyledProperty<int[]?> BinsProperty =
AvaloniaProperty.Register<SplineHistogramEditor, int[]?>(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<SplineHistogramEditor>(
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;
/// <summary>Raised on pointer release after a spline handle drag has completed.</summary>
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);
}
}
}