histogram implemented
This commit is contained in:
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
116
Plugins/LindtLeerformPlugin/Services/SplineCurve.cs
Normal file
116
Plugins/LindtLeerformPlugin/Services/SplineCurve.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
namespace LindtLeerformPlugin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
305
Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs
Normal file
305
Plugins/LindtLeerformPlugin/Views/SplineHistogramEditor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user