318 lines
11 KiB
C#
318 lines
11 KiB
C#
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;
|
||
}
|
||
}
|