Files
HawkeyeVision/Plugins/LindtLeerformPlugin/Services/LeerformPatternRecognitionService.cs
2026-04-08 13:10:34 +02:00

318 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using LindtLeerformPlugin.Models;
using OpenCvSharp;
namespace LindtLeerformPlugin.Services;
public class LeerformPatternRecognitionService
{
private static readonly Scalar GoodColor = new(0, 255, 0); // BGR Green
private static readonly Scalar BadColor = new(0, 0, 255); // BGR Red
private static readonly Scalar OverrideColor = new(0, 255, 255); // BGR Yellow
public IReadOnlyList<CellRegion> ComputeCells(CellPattern pattern)
{
var cells = new List<CellRegion>();
if (pattern.Rows <= 0 || pattern.Cols <= 0 || pattern.RoiWidth <= 0 || pattern.RoiHeight <= 0)
return cells;
var cellW = pattern.RoiWidth / pattern.Cols;
var cellH = pattern.RoiHeight / pattern.Rows;
var padding = Math.Max(0, pattern.Padding);
var index = 0;
for (var r = 0; r < pattern.Rows; r++)
{
for (var c = 0; c < pattern.Cols; c++)
{
var x = pattern.RoiX + c * cellW + padding;
var y = pattern.RoiY + r * cellH + padding;
var w = Math.Max(1, cellW - 2 * padding);
var h = Math.Max(1, cellH - 2 * padding);
var rect = new Rect(x, y, w, h);
var center = new Point(x + w / 2, y + h / 2);
var radius = Math.Max(1, Math.Min(w, h) / 2);
cells.Add(new CellRegion
{
Index = index++,
Row = r,
Col = c,
BoundingBox = rect,
Center = center,
Radius = radius
});
}
}
return cells;
}
public Mat BuildMask(CellPattern pattern, Size imageSize, int? cellIndex = null)
{
var mask = new Mat(imageSize, MatType.CV_8UC1, Scalar.All(0));
var cells = ComputeCells(pattern);
var color = Scalar.All(255);
var imageRect = new Rect(0, 0, imageSize.Width, imageSize.Height);
foreach (var cell in cells)
{
if (cellIndex.HasValue && cellIndex.Value != cell.Index)
continue;
var clipped = cell.BoundingBox & imageRect;
if (clipped.Width <= 0 || clipped.Height <= 0)
continue;
if (pattern.Shape == CellShape.Square)
{
Cv2.Rectangle(mask, clipped, color, thickness: -1);
}
else
{
Cv2.Circle(mask, cell.Center, cell.Radius, color, thickness: -1);
}
}
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)
{
bins ??= [5, 5, 5];
using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows));
using var hist = CalcHistogram(bgrImage, mask, bins);
return HistogramToArray(hist);
}
public IReadOnlyList<CellResult> EvaluateCells(Mat bgrImage, LeerformRecipe recipe)
{
var cells = ComputeCells(recipe.Pattern);
var bins = recipe.HistogramBins is { Length: 3 } ? recipe.HistogramBins : [5, 5, 5];
var imageSize = new Size(bgrImage.Cols, bgrImage.Rows);
var results = new List<CellResult>(cells.Count);
foreach (var cell in cells)
{
float[]? spline = null;
if (recipe.CellSplines != null
&& recipe.CellSplines.TryGetValue(cell.Index, out var custom)
&& custom is { Length: SplineCurve.KnotCount })
{
spline = custom;
}
else if (recipe.AverageSpline is { Length: SplineCurve.KnotCount })
{
spline = recipe.AverageSpline;
}
if (spline == null)
throw new InvalidOperationException(
$"Recipe has no spline for cell {cell.Index} — open Calibration and click 'Set Reference'.");
using var mask = BuildMask(recipe.Pattern, imageSize, cell.Index);
using var hist = CalcHistogram(bgrImage, mask, bins);
var arr = HistogramToArray(hist);
var (isGood, exceed) = EvaluateAgainstSpline(arr, spline);
results.Add(new CellResult
{
Index = cell.Index,
IsGood = isGood,
MaxExceedance = exceed
});
}
return 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 cells = ComputeCells(pattern);
var resultByIndex = results.ToDictionary(r => r.Index);
var imageRect = new Rect(0, 0, overlay.Cols, overlay.Rows);
const int thickness = 2;
foreach (var cell in cells)
{
if (!resultByIndex.TryGetValue(cell.Index, out var result))
continue;
var color = result.IsGood ? GoodColor : BadColor;
if (pattern.Shape == CellShape.Square)
Cv2.Rectangle(overlay, cell.BoundingBox, color, thickness);
else
Cv2.Circle(overlay, cell.Center, cell.Radius, color, thickness);
if (overriddenCells != null && overriddenCells.Contains(cell.Index))
DrawOverrideMarker(overlay, cell, pattern.Shape, imageRect);
}
return overlay;
}
public void DrawPattern(Mat target, CellPattern pattern, Scalar color, int thickness = 2)
{
var cells = ComputeCells(pattern);
foreach (var cell in cells)
{
if (pattern.Shape == CellShape.Square)
Cv2.Rectangle(target, cell.BoundingBox, color, thickness);
else
Cv2.Circle(target, cell.Center, cell.Radius, color, thickness);
}
}
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)
{
var cells = ComputeCells(pattern);
foreach (var cell in cells)
{
if (pattern.Shape == CellShape.Square)
{
if (cell.BoundingBox.Contains(x, y))
return cell.Index;
}
else
{
var dx = x - cell.Center.X;
var dy = y - cell.Center.Y;
if (dx * dx + dy * dy <= cell.Radius * cell.Radius)
return cell.Index;
}
}
return null;
}
public float[] ComputeCellHistogram(Mat bgrImage, CellPattern pattern, int cellIndex, int[]? bins = null)
{
bins ??= [5, 5, 5];
using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows), cellIndex);
using var hist = CalcHistogram(bgrImage, mask, bins);
return HistogramToArray(hist);
}
public static Mat RenderHistogramChart(float[] histogram, int[] bins, int width = 600, int height = 220)
{
var chart = new Mat(height, width, MatType.CV_8UC3, new Scalar(30, 30, 30));
if (histogram == null || histogram.Length == 0)
return chart;
var totalBins = bins[0] * bins[1] * bins[2];
var max = 0f;
for (var i = 0; i < histogram.Length; i++)
if (histogram[i] > max) max = histogram[i];
if (max <= 0)
return chart;
const int leftPad = 10;
const int bottomPad = 15;
var barWidth = Math.Max(1, (width - 2 * leftPad) / totalBins);
var maxBarHeight = height - bottomPad - 10;
for (var i = 0; i < totalBins && i < histogram.Length; i++)
{
var binB = i / (bins[1] * bins[2]);
var binG = (i / bins[2]) % bins[1];
var binR = i % bins[2];
var b = (int)((binB + 0.5) * 256 / bins[0]);
var g = (int)((binG + 0.5) * 256 / bins[1]);
var r = (int)((binR + 0.5) * 256 / bins[2]);
var barHeight = (int)(histogram[i] / max * maxBarHeight);
var x = leftPad + i * barWidth;
var y = height - bottomPad - barHeight;
Cv2.Rectangle(chart, new Rect(x, y, barWidth, barHeight), new Scalar(b, g, r), -1);
}
return chart;
}
private static Mat CalcHistogram(Mat bgrImage, Mat mask, int[] bins)
{
var hist = new Mat();
Cv2.CalcHist(
images: new[] { bgrImage },
channels: new[] { 0, 1, 2 },
mask: mask,
hist: hist,
dims: 3,
histSize: bins,
ranges: new[] { new Rangef(0, 256), new Rangef(0, 256), new Rangef(0, 256) });
// L1 normalize so each bin is a probability in [0, 1] summing to 1.
Cv2.Normalize(hist, hist, 1.0, 0.0, NormTypes.L1);
var totalBins = bins[0] * bins[1] * bins[2];
var reshaped = hist.Reshape(1, totalBins).Clone();
hist.Dispose();
return reshaped;
}
private static float[] HistogramToArray(Mat hist)
{
var totalBins = hist.Rows * hist.Cols;
var arr = new float[totalBins];
for (var i = 0; i < totalBins; i++)
arr[i] = hist.At<float>(i, 0);
return arr;
}
}