feat: trim textured background (wood grain) touching a smooth piece

Keep the piece's smooth core, then grow it back to restore the true edges.
Removes the wood-knot and grain leaks on the MDF-on-floor photo. Pieces that
are textured themselves are left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
immortaly007andClaude Opus 5.5 committed 2026-10-15 15:25:53 +02:00
1 parent 077c3cc31d
commit e925ce66d5
4 files changed
+112 -11

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
import { detectPrepared, prepareImage, preparedThreshold, type PreparedImage } from './detect'; import { detectPrepared, disposePrepared, prepareImage, preparedThreshold, type PreparedImage } from './detect';
import { prepareForExport } from './geometry'; import { prepareForExport } from './geometry';
import { loadPhoto } from './load-image'; import { loadPhoto } from './load-image';
import { shapeFromPolygons } from './margin'; import { shapeFromPolygons } from './margin';
@@ -69,7 +69,7 @@ export class AppState {
} }
private prepare() { private prepare() {
this.prepared?.gray.delete(); if (this.prepared) disposePrepared(this.prepared);
const { cv, image } = this; const { cv, image } = this;
this.prepared = cv && image ? prepareImage(cv, image) : null; this.prepared = cv && image ? prepareImage(cv, image) : null;
this.threshold = cv && this.prepared ? preparedThreshold(cv, this.prepared) : DEFAULT_THRESHOLD; this.threshold = cv && this.prepared ? preparedThreshold(cv, this.prepared) : DEFAULT_THRESHOLD;
+86 -5
View File
@@ -2,8 +2,15 @@ import type { Mat } from '@techstark/opencv-js';
import type { CV } from './opencv-loader'; import type { CV } from './opencv-loader';
import type { DetectParams, Point, RasterImage, Shape } from './types'; import type { DetectParams, Point, RasterImage, Shape } from './types';
/** Blurred, lighting-corrected grayscale plus the background brightness. Caller deletes `gray`. */ /** Per-photo detection inputs. Owns WASM memory: release with `disposePrepared`. */
export type PreparedImage = { gray: Mat; background: number }; export type PreparedImage = {
/** Blurred, lighting-corrected grayscale. */
gray: Mat;
background: number;
/** Local brightness variance (CV_32F); high on wood grain, low on smooth sheet material. */
texture: Mat;
textureRadius: number;
};
/** The per-photo part of detection; reuse it across threshold, click and margin changes. */ /** The per-photo part of detection; reuse it across threshold, click and margin changes. */
export function prepareImage(cv: CV, image: RasterImage): PreparedImage { export function prepareImage(cv: CV, image: RasterImage): PreparedImage {
@@ -11,13 +18,39 @@ export function prepareImage(cv: CV, image: RasterImage): PreparedImage {
const gray = new cv.Mat(); const gray = new cv.Mat();
try { try {
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY); cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
const window = Math.max(5, 2 * Math.round(Math.min(gray.cols, gray.rows) / 120) + 1);
const texture = localVariance(cv, gray, window);
cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0); cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0);
return { gray, background: flattenLighting(gray) }; return { gray, background: flattenLighting(gray), texture, textureRadius: (window - 1) / 2 + 2 };
} finally { } finally {
src.delete(); src.delete();
} }
} }
export function disposePrepared(prepared: PreparedImage) {
prepared.gray.delete();
prepared.texture.delete();
}
function localVariance(cv: CV, gray: Mat, window: number): Mat {
const size = new cv.Size(window, window);
const f = new cv.Mat();
const mean = new cv.Mat();
const variance = new cv.Mat();
try {
gray.convertTo(f, cv.CV_32F);
cv.blur(f, mean, size);
cv.multiply(f, f, variance);
cv.blur(variance, variance, size);
cv.multiply(mean, mean, mean);
cv.subtract(variance, mean, variance);
return variance;
} finally {
f.delete();
mean.delete();
}
}
/** /**
* Removes smooth lighting gradients (side light, vignetting) by fitting a quadratic surface to the * 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. * photo's border band, which is almost always background. Returns the background brightness.
@@ -93,7 +126,7 @@ export function otsuThreshold(cv: CV, image: RasterImage): number {
try { try {
return preparedThreshold(cv, prepared); return preparedThreshold(cv, prepared);
} finally { } finally {
prepared.gray.delete(); disposePrepared(prepared);
} }
} }
@@ -180,7 +213,7 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape
try { try {
return detectPrepared(cv, prepared, params); return detectPrepared(cv, prepared, params);
} finally { } finally {
prepared.gray.delete(); disposePrepared(prepared);
} }
} }
@@ -210,6 +243,7 @@ export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectPa
const lab = labels.data32S; const lab = labels.data32S;
const m = mask.data; const m = mask.data;
for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0; for (let i = 0; i < lab.length; i++) m[i] = lab[i] === label ? 255 : 0;
trimTexturedEdges(cv, mask, prepared.texture, prepared.textureRadius);
fillSmallHoles(cv, mask, params.minHolePx); fillSmallHoles(cv, mask, params.minHolePx);
return traceMask(cv, mask, params.marginPx); return traceMask(cv, mask, params.marginPx);
} finally { } finally {
@@ -291,3 +325,50 @@ function seedIsDark(gray: Mat, threshold: number, seed: Point | null): boolean |
if (x < 0 || y < 0 || x >= gray.cols || y >= gray.rows) return null; if (x < 0 || y < 0 || x >= gray.cols || y >= gray.rows) return null;
return gray.ucharAt(y, x) <= threshold; return gray.ucharAt(y, x) <= threshold;
} }
/**
* Removes textured regions (wood grain, knots) that touch a smoother piece and passed the threshold.
* Keeps only the smooth core, then grows it back by the texture window to restore the true edges,
* which always look textured. A piece that is itself textured (core under half its area) is left alone.
*/
function trimTexturedEdges(cv: CV, mask: Mat, texture: Mat, radius: number) {
const md = mask.data;
const tv = texture.data32F;
const samples: number[] = [];
for (let i = 0; i < md.length; i += 7) if (md[i]) samples.push(tv[i]);
if (samples.length === 0) return;
samples.sort((a, b) => a - b);
// Variance limit: 1.6x the piece's typical deviation, and never below a deviation of 4 grey levels.
const limit = Math.max(16, 2.56 * samples[samples.length >> 1]);
// copyTo, not clone(): in this opencv.js build clone() shares the pixel buffer.
const core = new cv.Mat();
mask.copyTo(core);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
try {
const cd = core.data;
for (let i = 0; i < cd.length; i++) if (cd[i] && tv[i] > limit) cd[i] = 0;
const count = cv.connectedComponentsWithStats(core, labels, stats, centroids, 8, cv.CV_32S);
let best = 0;
let bestArea = 0;
for (let label = 1; label < count; label++) {
const area = stats.intAt(label, cv.CC_STAT_AREA);
if (area > bestArea) {
bestArea = area;
best = label;
}
}
if (bestArea < 0.5 * cv.countNonZero(mask)) return;
const lab = labels.data32S;
for (let i = 0; i < cd.length; i++) cd[i] = lab[i] === best ? 255 : 0;
offsetMask(cv, core, radius);
cv.bitwise_and(mask, core, mask);
} finally {
core.delete();
labels.delete();
stats.delete();
centroids.delete();
}
}
+22 -2
View File
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { detect, detectPrepared, otsuThreshold, prepareImage, preparedThreshold } from '../src/lib/detect'; import { detect, detectPrepared, disposePrepared, otsuThreshold, prepareImage, preparedThreshold } from '../src/lib/detect';
import { bounds } from '../src/lib/geometry'; import { bounds } from '../src/lib/geometry';
import { loadOpenCV, type CV } from '../src/lib/opencv-loader'; import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
import type { DetectParams } from '../src/lib/types'; import type { DetectParams } from '../src/lib/types';
@@ -126,7 +126,27 @@ describe('detect', () => {
expect(reused.holes).toHaveLength(direct.holes.length); expect(reused.holes).toHaveLength(direct.holes.length);
} }
} finally { } finally {
prepared.gray.delete(); disposePrepared(prepared);
} }
}); });
it('cuts off textured background (wood grain) that touches a smooth piece', () => {
// Smooth dark piece x 150..449, y 100..299 on a light floor, with a dark grainy knot touching its top-left.
const knotted = grayImage(600, 450, (x, y) => {
if (inRect(x, y, 150, 100, 450, 300)) return 80;
if (inRect(x, y, 90, 50, 170, 110)) return Math.floor(x / 3) % 2 ? 40 : 110;
return 200;
});
const b = bounds(detect(cv, knotted, params({ threshold: 140 }))!.outer);
expect(b.minX).toBeGreaterThanOrEqual(144);
expect(b.minY).toBeGreaterThanOrEqual(94);
near(b.maxX, 449); near(b.maxY, 299);
});
it('keeps a piece that is itself textured (e.g. plywood grain)', () => {
const plywood = grayImage(600, 450, (x, y) =>
inRect(x, y, 150, 100, 450, 300) ? (Math.floor(x / 3) % 2 ? 40 : 110) : 200);
const b = bounds(detect(cv, plywood, params({ threshold: 140 }))!.outer);
near(b.minX, 150); near(b.maxX, 449); near(b.minY, 100); near(b.maxY, 299);
});
}); });
+2 -2
View File
@@ -4,7 +4,7 @@
"threshold": null, "threshold": null,
"seed": null, "seed": null,
"pxPerMm": 1, "pxPerMm": 1,
"expected": { "widthMm": 1714, "heightMm": 675 }, "expected": { "widthMm": 1712, "heightMm": 675, "holes": 0 },
"toleranceMm": 60 "toleranceMm": 20
} }
] ]