feat: correct uneven lighting and pick polarity automatically

Fit the background brightness from the photo border and subtract it, so
side-lit photos threshold cleanly. The clicked pixel (or, without a click,
the border) decides whether the piece is darker or lighter, replacing the
invert checkbox. Adds the MDF-on-wood-floor photo as a regression fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:12:16 +02:00
1 parent 41baa25a01
commit 55a25ba978
10 files changed
+135 -36

No files matched your search

+95 -12
View File
@@ -2,21 +2,91 @@ import type { Mat } from '@techstark/opencv-js';
import type { CV } from './opencv-loader';
import type { DetectParams, Point, RasterImage, Shape } from './types';
/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */
function toGray(cv: CV, image: RasterImage): Mat {
/** Blurred, lighting-corrected grayscale; shared by thresholding and Otsu so both see the same pixels. */
function toGray(cv: CV, image: RasterImage): { gray: Mat; background: number } {
const src = cv.matFromImageData(image as unknown as ImageData);
const gray = new cv.Mat();
try {
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0);
return gray;
return { gray, background: flattenLighting(gray) };
} finally {
src.delete();
}
}
/**
* Removes smooth lighting gradients (side light, vignetting) by fitting a quadratic surface to the
* photo's border band, which is almost always background. Returns the background brightness.
*/
function flattenLighting(gray: Mat): number {
const w = gray.cols;
const h = gray.rows;
const g = gray.data;
const band = Math.max(1, Math.round(Math.min(w, h) * 0.08));
const step = Math.max(1, Math.round(Math.min(w, h) / 400));
const terms = (x: number, y: number) => {
const u = x / w - 0.5;
const v = y / h - 0.5;
return [1, u, v, u * u, u * v, v * v];
};
const samples: { t: number[]; v: number }[] = [];
for (let y = 0; y < h; y += step) {
for (let x = 0; x < w; x += step) {
if (x >= band && x < w - band && y >= band && y < h - band) continue;
samples.push({ t: terms(x, y), v: g[y * w + x] });
}
}
// Keep samples near the median first, so a piece touching the border barely affects the fit.
const sorted = samples.map((s) => s.v).sort((a, b) => a - b);
const median = sorted[sorted.length >> 1];
const mad = samples.map((s) => Math.abs(s.v - median)).sort((a, b) => a - b)[samples.length >> 1];
const nearMedian = samples.filter((s) => Math.abs(s.v - median) <= 3.7 * mad + 2);
let coef = fitSurface(nearMedian);
// Second pass drops outliers (a piece touching the border) so they don't skew the fit.
const residuals = nearMedian.map((s) => s.v - dot(coef, s.t));
const sd = Math.sqrt(residuals.reduce((a, r) => a + r * r, 0) / residuals.length);
const inliers = nearMedian.filter((_, i) => Math.abs(residuals[i]) <= 2 * sd + 1);
if (inliers.length >= 6) coef = fitSurface(inliers);
const level = inliers.reduce((a, s) => a + s.v, 0) / inliers.length;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
g[i] = Math.max(0, Math.min(255, Math.round(g[i] - dot(coef, terms(x, y)) + level)));
}
}
return level;
}
const dot = (a: number[], b: number[]) => a.reduce((sum, ai, i) => sum + ai * b[i], 0);
/** Least-squares fit via the normal equations, solved with Gaussian elimination. */
function fitSurface(samples: { t: number[]; v: number }[]): number[] {
const n = 6;
const m = Array.from({ length: n }, () => new Array<number>(n + 1).fill(0));
for (const { t, v } of samples) {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) m[i][j] += t[i] * t[j];
m[i][n] += t[i] * v;
}
}
for (let c = 0; c < n; c++) {
let pivot = c;
for (let r = c + 1; r < n; r++) if (Math.abs(m[r][c]) > Math.abs(m[pivot][c])) pivot = r;
[m[c], m[pivot]] = [m[pivot], m[c]];
if (Math.abs(m[c][c]) < 1e-12) continue;
for (let r = 0; r < n; r++) {
if (r === c) continue;
const f = m[r][c] / m[c][c];
for (let k = c; k <= n; k++) m[r][k] -= f * m[c][k];
}
}
return m.map((row, i) => (Math.abs(row[i]) < 1e-12 ? 0 : row[n] / row[i]));
}
export function otsuThreshold(cv: CV, image: RasterImage): number {
const gray = toGray(cv, image);
const { gray } = toGray(cv, image);
const out = new cv.Mat();
try {
return Math.round(cv.threshold(gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU));
@@ -81,20 +151,24 @@ export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
}
export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape | null {
const gray = toGray(cv, image);
const { gray, background } = toGray(cv, image);
const mask = new cv.Mat();
const kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(5, 5));
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
try {
const type = params.invert ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY;
cv.threshold(gray, mask, params.threshold, 255, type);
cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel);
cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel);
const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S);
const label = pickComponent(cv, labels, stats, count, image.width, image.height, params.seed);
const segment = (dark: boolean, seed: Point | null) => {
cv.threshold(gray, mask, params.threshold, 255, dark ? cv.THRESH_BINARY_INV : cv.THRESH_BINARY);
cv.morphologyEx(mask, mask, cv.MORPH_OPEN, kernel);
cv.morphologyEx(mask, mask, cv.MORPH_CLOSE, kernel);
const count = cv.connectedComponentsWithStats(mask, labels, stats, centroids, 8, cv.CV_32S);
return pickComponent(cv, labels, stats, count, image.width, image.height, seed);
};
const seedDark = seedIsDark(gray, params.threshold, params.seed);
let label = seedDark === null ? null : segment(seedDark, params.seed);
// A click on the background makes it "the piece", which is rejected; fall back to the automatic choice.
if (label === null) label = segment(background > params.threshold, null);
if (label === null) return null;
const lab = labels.data32S;
@@ -173,3 +247,12 @@ function fillSmallHoles(cv: CV, mask: Mat, minHolePx: number) {
hierarchy.delete();
}
}
/** The clicked pixel's side of the threshold is the piece's; null without a usable click. */
function seedIsDark(gray: Mat, threshold: number, seed: Point | null): boolean | null {
if (!seed) return null;
const x = Math.round(seed.x);
const y = Math.round(seed.y);
if (x < 0 || y < 0 || x >= gray.cols || y >= gray.rows) return null;
return gray.ucharAt(y, x) <= threshold;
}