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 ComputeCells(CellPattern pattern) { var cells = new List(); 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; } /// /// Aggregate histogram pooled across every cell in the pattern. Used only at calibration /// time to seed the default average spline; not persisted in the recipe. Note that pooling /// weights cells by their pixel count rather than averaging per-cell histograms. /// public float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null) { bins ??= [5, 5, 5]; using var mask = BuildMask(pattern, new Size(bgrImage.Cols, bgrImage.Rows)); using var hist = CalcHistogram(bgrImage, mask, bins); return HistogramToArray(hist); } public IReadOnlyList 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(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; } /// /// Cell is good when no histogram bin rises above the spline. maxExceedance is the /// largest (bin − spline) across all bins; non-positive means the cell passes. /// public static (bool isGood, double maxExceedance) EvaluateAgainstSpline(float[] histogram, float[] spline) { if (histogram == null || histogram.Length == 0) return (true, 0); var maxExceed = double.NegativeInfinity; for (var i = 0; i < histogram.Length; i++) { var threshold = SplineCurve.EvaluateAtBin(spline, i, histogram.Length); var diff = histogram[i] - threshold; if (diff > maxExceed) maxExceed = diff; } return (maxExceed <= 0, maxExceed); } public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList results, IReadOnlySet? overriddenCells = null) { var overlay = bgrImage.Clone(); var cells = ComputeCells(pattern); var resultByIndex = results.ToDictionary(r => r.Index); var imageRect = new Rect(0, 0, overlay.Cols, overlay.Rows); const int thickness = 2; foreach (var cell in cells) { 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 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(i, 0); return arr; } }