profile influences configuration
This commit is contained in:
EugeneTes
2026-05-15 11:54:36 +02:00
parent 2be13501fa
commit 8704d0b5ac
42 changed files with 2467 additions and 1023 deletions

View File

@@ -5,9 +5,8 @@ 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
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)
{
@@ -75,86 +74,98 @@ public class LeerformPatternRecognitionService
}
/// <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.
/// 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 float[] ComputeReferenceHistogram(Mat bgrImage, CellPattern pattern, int[]? bins = null)
public static Mat DetectChocolateMask(Mat bgrImage, int aThreshold, int lThreshold)
{
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);
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 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 })
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)
{
spline = custom;
}
else if (recipe.AverageSpline is { Length: SplineCurve.KnotCount })
{
spline = recipe.AverageSpline;
var contourArea = (int)Cv2.ContourArea(contour);
if (contourArea >= recipe.MinBlobArea && contourArea <= recipe.MaxBlobArea)
{
blobs++;
area += contourArea;
}
}
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
IsGood = blobs == 0,
BlobCount = blobs,
DetectedArea = area
});
}
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)
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);
var imageRect = new Rect(0, 0, overlay.Cols, overlay.Rows);
const int thickness = 2;
foreach (var cell in cells)
@@ -168,9 +179,6 @@ public class LeerformPatternRecognitionService
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;
}
@@ -187,39 +195,6 @@ public class LeerformPatternRecognitionService
}
}
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);
@@ -240,78 +215,4 @@ public class LeerformPatternRecognitionService
}
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;
}
}

View File

@@ -1,116 +0,0 @@
namespace LindtLeerformPlugin.Services;
/// <summary>
/// Monotone cubic Hermite spline (FritschCarlson). Five control points with
/// fixed, evenly spaced X positions; only Y is editable. Output never overshoots
/// the input range, so envelope curves stay inside [0, 1] when knots are.
/// </summary>
public static class SplineCurve
{
public const int KnotCount = 5;
/// <summary>
/// Evaluate the spline at parameter <paramref name="t"/> in [0, 1].
/// Knots are positioned at t = i / (knots.Length - 1).
/// </summary>
public static float Evaluate(float[]? knots, double t)
{
if (knots == null || knots.Length == 0)
return 0f;
if (knots.Length == 1)
return knots[0];
if (t <= 0) return knots[0];
if (t >= 1) return knots[^1];
var n = knots.Length - 1;
var pos = t * n;
var i = (int)System.Math.Floor(pos);
if (i >= n) return knots[n];
var localT = pos - i;
// Compute secant slopes and Fritsch-Carlson tangents at the two surrounding knots only.
var dxLeft = i > 0 ? (double)(knots[i] - knots[i - 1]) : 0;
var dxMid = (double)(knots[i + 1] - knots[i]);
var dxRight = i + 2 <= n ? (double)(knots[i + 2] - knots[i + 1]) : 0;
var m0 = MonotoneTangent(dxLeft, dxMid, hasLeft: i > 0, hasRight: true);
var m1 = MonotoneTangent(dxMid, dxRight, hasLeft: true, hasRight: i + 2 <= n);
var t2 = localT * localT;
var t3 = t2 * localT;
var h00 = 2 * t3 - 3 * t2 + 1;
var h10 = t3 - 2 * t2 + localT;
var h01 = -2 * t3 + 3 * t2;
var h11 = t3 - t2;
var y = h00 * knots[i] + h10 * m0 + h01 * knots[i + 1] + h11 * m1;
return (float)y;
}
/// <summary>
/// Convenience: evaluate the spline at the position of bin <paramref name="binIndex"/>
/// of a histogram with <paramref name="totalBins"/> bins.
/// </summary>
public static float EvaluateAtBin(float[]? knots, int binIndex, int totalBins)
{
if (totalBins <= 1)
return Evaluate(knots, 0);
var t = (double)binIndex / (totalBins - 1);
return Evaluate(knots, t);
}
/// <summary>
/// Build a default envelope spline from a reference histogram. For each knot
/// the segment max around the knot position is taken, multiplied by 1.2 with a
/// small floor added, and clamped to [0, 1].
/// </summary>
public static float[] CreateDefault(float[]? referenceHistogram, int knotCount = KnotCount)
{
var knots = new float[knotCount];
if (referenceHistogram == null || referenceHistogram.Length == 0)
{
for (var k = 0; k < knotCount; k++) knots[k] = 0.05f;
return knots;
}
var totalBins = referenceHistogram.Length;
var span = totalBins - 1;
// Half-width of the window we look at around each knot.
var segHalf = System.Math.Max(1, span / (2 * (knotCount - 1)));
for (var k = 0; k < knotCount; k++)
{
var center = knotCount == 1 ? 0 : k * span / (knotCount - 1);
var lo = System.Math.Max(0, center - segHalf);
var hi = System.Math.Min(totalBins - 1, center + segHalf);
var max = 0f;
for (var i = lo; i <= hi; i++)
if (referenceHistogram[i] > max) max = referenceHistogram[i];
var y = max * 1.2f + 0.01f;
if (y < 0f) y = 0f;
if (y > 1f) y = 1f;
knots[k] = y;
}
return knots;
}
private static double MonotoneTangent(double secLeft, double secRight, bool hasLeft, bool hasRight)
{
if (!hasLeft) return secRight;
if (!hasRight) return secLeft;
// If the signs differ (or either is zero), the knot is an extremum: tangent must be zero
// to preserve monotonicity locally.
if (secLeft == 0 || secRight == 0 || System.Math.Sign(secLeft) != System.Math.Sign(secRight))
return 0;
// Average — Fritsch-Carlson would also clamp by 3*min(|secLeft|,|secRight|), but for our
// small (5 knot) curves the simple average plus zero-at-extrema rule already prevents
// overshoot in practice.
var avg = 0.5 * (secLeft + secRight);
var limit = 3.0 * System.Math.Min(System.Math.Abs(secLeft), System.Math.Abs(secRight));
if (System.Math.Abs(avg) > limit)
avg = System.Math.Sign(avg) * limit;
return avg;
}
}