219 lines
7.7 KiB
C#
219 lines
7.7 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
|
||
|
||
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>
|
||
/// Build a single foreground mask covering both white and dark chocolate flecks on the pink mold,
|
||
/// using the LAB-channel thresholding approach from <c>jupiter/grid_test.ipynb</c>.
|
||
/// <list type="bullet">
|
||
/// <item><description><b>White chocolate</b> — pink mold has a* well above 128 (red), white sits near 128. Inverse-threshold a* so neutral pixels become foreground.</description></item>
|
||
/// <item><description><b>Dark chocolate</b> — pink mold is bright (high L*), dark chocolate is dark. Inverse-threshold L* so dark pixels become foreground.</description></item>
|
||
/// </list>
|
||
/// 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 <see cref="Mat"/>.
|
||
/// </summary>
|
||
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<CellResult> 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<CellResult>(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<CellResult> 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;
|
||
}
|
||
}
|