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:
1 parent
41baa25a01
commit
55a25ba978
10 files changed
+135
-36
No files matched your search
+21
-10
@@ -1,5 +1,5 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { detect } from '../src/lib/detect';
|
||||
import { detect, otsuThreshold } from '../src/lib/detect';
|
||||
import { bounds } from '../src/lib/geometry';
|
||||
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
|
||||
import type { DetectParams } from '../src/lib/types';
|
||||
@@ -11,7 +11,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
const params = (overrides: Partial<DetectParams> = {}): DetectParams => ({
|
||||
threshold: 128, invert: false, seed: null, marginPx: 0, minHolePx: 0, ...overrides,
|
||||
threshold: 128, seed: null, marginPx: 0, minHolePx: 0, ...overrides,
|
||||
});
|
||||
|
||||
/** Light plate x 40..159, y 30..129 with hole r=15 at (100,80), plus a small light blob, on dark ground. */
|
||||
@@ -31,24 +31,35 @@ describe('detect', () => {
|
||||
expect(shape.holes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles a dark piece on a light background with invert', () => {
|
||||
it('finds a dark piece on a light background without any setting', () => {
|
||||
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
|
||||
expect(detect(cv, inverted, params())).toBeNull();
|
||||
const b = bounds(detect(cv, inverted, params({ invert: true }))!.outer);
|
||||
const b = bounds(detect(cv, inverted, params())!.outer);
|
||||
near(b.minX, 40); near(b.maxX, 159);
|
||||
});
|
||||
|
||||
it('takes the polarity from the clicked pixel', () => {
|
||||
const inverted = grayImage(200, 160, (x, y) => 250 - (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
|
||||
const b = bounds(detect(cv, inverted, params({ seed: { x: 100, y: 80 } }))!.outer);
|
||||
near(b.minX, 40); near(b.maxX, 159);
|
||||
});
|
||||
|
||||
it('corrects uneven lighting across the photo', () => {
|
||||
// Light from the right: the floor ramps 120 → 220 and the piece is 50 darker than the floor around it.
|
||||
const sideLit = grayImage(400, 300, (x, y) => 120 + (100 * x) / 400 - (inRect(x, y, 100, 75, 300, 225) ? 50 : 0));
|
||||
const shape = detect(cv, sideLit, params({ threshold: otsuThreshold(cv, sideLit) }))!;
|
||||
const b = bounds(shape.outer);
|
||||
near(b.minX, 100); near(b.maxX, 299); near(b.minY, 75); near(b.maxY, 224);
|
||||
});
|
||||
|
||||
it('selects the clicked piece even when another piece is larger', () => {
|
||||
const shape = detect(cv, plateScene, params({ seed: { x: 177, y: 146 } }))!;
|
||||
const b = bounds(shape.outer);
|
||||
near(b.minX, 170); near(b.maxX, 184);
|
||||
});
|
||||
|
||||
it('falls back to the automatic choice when the seed is on background or in a hole', () => {
|
||||
for (const seed of [{ x: 5, y: 5 }, { x: 100, y: 80 }]) {
|
||||
const b = bounds(detect(cv, plateScene, params({ seed }))!.outer);
|
||||
near(b.minX, 40);
|
||||
}
|
||||
it('falls back to the automatic choice when the click is on the background', () => {
|
||||
const b = bounds(detect(cv, plateScene, params({ seed: { x: 5, y: 5 } }))!.outer);
|
||||
near(b.minX, 40);
|
||||
});
|
||||
|
||||
it('ignores a seed outside the image', () => {
|
||||
|
||||
@@ -10,10 +10,9 @@ import type { Point, RasterImage } from '../src/lib/types';
|
||||
type FixtureCase = {
|
||||
file: string;
|
||||
threshold: number | null;
|
||||
invert: boolean;
|
||||
seed: Point | null;
|
||||
pxPerMm: number;
|
||||
expected: { widthMm: number; heightMm: number; holes: number };
|
||||
expected: { widthMm: number; heightMm: number; holes?: number };
|
||||
toleranceMm: number;
|
||||
};
|
||||
|
||||
@@ -34,12 +33,12 @@ describe.skipIf(cases.length === 0)('detect on real photos', () => {
|
||||
it.each(cases)('$file', (c) => {
|
||||
const image = readJpeg(dir + c.file);
|
||||
const threshold = c.threshold ?? otsuThreshold(cv, image);
|
||||
const shape = detect(cv, image, { threshold, invert: c.invert, seed: c.seed, marginPx: 0, minHolePx: 3 * c.pxPerMm });
|
||||
const shape = detect(cv, image, { threshold, seed: c.seed, marginPx: 0, minHolePx: 3 * c.pxPerMm });
|
||||
expect(shape).not.toBeNull();
|
||||
const mm = prepareForExport(shape!, c.pxPerMm)!;
|
||||
const size = shapeSize(mm);
|
||||
expect(Math.abs(size.width - c.expected.widthMm)).toBeLessThanOrEqual(c.toleranceMm);
|
||||
expect(Math.abs(size.height - c.expected.heightMm)).toBeLessThanOrEqual(c.toleranceMm);
|
||||
expect(mm.holes).toHaveLength(c.expected.holes);
|
||||
if (c.expected.holes !== undefined) expect(mm.holes).toHaveLength(c.expected.holes);
|
||||
});
|
||||
});
|
||||
Vendored
+2
-2
@@ -16,7 +16,6 @@ To add one:
|
||||
{
|
||||
"file": "plywood-1.jpg",
|
||||
"threshold": null,
|
||||
"invert": false,
|
||||
"seed": null,
|
||||
"pxPerMm": 3.21,
|
||||
"expected": { "widthMm": 412, "heightMm": 287, "holes": 2 },
|
||||
@@ -24,4 +23,5 @@ To add one:
|
||||
}
|
||||
```
|
||||
|
||||
`threshold: null` means Otsu's automatic value. `seed` is `{ "x": ..., "y": ... }` or `null`.
|
||||
`threshold: null` means Otsu's automatic value. `holes` is optional; leave it out when
|
||||
background texture creates noise holes. Without a known scale, use `pxPerMm: 1` and sizes in px. `seed` is `{ "x": ..., "y": ... }` or `null`.
|
||||
Vendored
+10
-1
@@ -1 +1,10 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"file": "mdf_floor.jpeg",
|
||||
"threshold": null,
|
||||
"seed": null,
|
||||
"pxPerMm": 1,
|
||||
"expected": { "widthMm": 1714, "heightMm": 675 },
|
||||
"toleranceMm": 60
|
||||
}
|
||||
]
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
Reference in new issue
Block a user