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 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; } /// /// Build a single foreground mask covering both white and dark chocolate flecks on the pink mold, /// using the LAB-channel thresholding approach from jupiter/grid_test.ipynb. /// /// White chocolate — pink mold has a* well above 128 (red), white sits near 128. Inverse-threshold a* so neutral pixels become foreground. /// Dark chocolate — pink mold is bright (high L*), dark chocolate is dark. Inverse-threshold L* so dark pixels become foreground. /// /// The two masks are OR-merged, then morphologically opened (3×3 ELLIPSE) and closed (5×5 ELLIPSE) /// to drop single-pixel noise and merge speck fragments. Caller owns the returned . /// public static Mat DetectChocolateMask(Mat bgrImage, int aThreshold, int lThreshold) { using var lab = new Mat(); Cv2.CvtColor(bgrImage, lab, ColorConversionCodes.BGR2Lab); var channels = Cv2.Split(lab); try { var lChan = channels[0]; var aChan = channels[1]; using var whiteMask = new Mat(); Cv2.Threshold(aChan, whiteMask, aThreshold, 255, ThresholdTypes.BinaryInv); using var darkMask = new Mat(); Cv2.Threshold(lChan, darkMask, lThreshold, 255, ThresholdTypes.BinaryInv); var combined = new Mat(); Cv2.BitwiseOr(whiteMask, darkMask, combined); using var openKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3)); using var closeKernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(5, 5)); Cv2.MorphologyEx(combined, combined, MorphTypes.Open, openKernel); Cv2.MorphologyEx(combined, combined, MorphTypes.Close, closeKernel); return combined; } finally { foreach (var ch in channels) ch.Dispose(); } } public IReadOnlyList EvaluateCells(Mat bgrImage, LeerformRecipe recipe) { using var chocolateMask = DetectChocolateMask(bgrImage, recipe.AThreshold, recipe.LThreshold); var cells = ComputeCells(recipe.Pattern); var imageSize = new Size(bgrImage.Cols, bgrImage.Rows); var results = new List(cells.Count); foreach (var cell in cells) { using var cellMask = BuildMask(recipe.Pattern, imageSize, cell.Index); using var perCell = new Mat(); Cv2.BitwiseAnd(chocolateMask, cellMask, perCell); Cv2.FindContours( perCell, out var contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); var blobs = 0; var area = 0; foreach (var contour in contours) { var contourArea = (int)Cv2.ContourArea(contour); if (contourArea >= recipe.MinBlobArea && contourArea <= recipe.MaxBlobArea) { blobs++; area += contourArea; } } results.Add(new CellResult { Index = cell.Index, IsGood = blobs == 0, BlobCount = blobs, DetectedArea = area }); } return results; } public Mat RenderOverlay(Mat bgrImage, CellPattern pattern, IReadOnlyList results) { var overlay = bgrImage.Clone(); var cells = ComputeCells(pattern); var resultByIndex = results.ToDictionary(r => r.Index); 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); } 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 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; } }