From e925ce66d5e215accd3b6c6a1bab8fbd10a6db4e Mon Sep 17 00:00:00 2001 From: Bas Dado Date: Thu, 15 Oct 2026 15:25:53 +0200 Subject: [PATCH] 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 --- src/lib/app-state.svelte.ts | 4 +- src/lib/detect.ts | 91 ++++++++++++++++++++++++++++++++++-- tests/detect.test.ts | 24 +++++++++- tests/fixtures/manifest.json | 4 +- 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/lib/app-state.svelte.ts b/src/lib/app-state.svelte.ts index 4afe5a1..6122d2f 100644 --- a/src/lib/app-state.svelte.ts +++ b/src/lib/app-state.svelte.ts @@ -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 { loadPhoto } from './load-image'; import { shapeFromPolygons } from './margin'; @@ -69,7 +69,7 @@ export class AppState { } private prepare() { - this.prepared?.gray.delete(); + if (this.prepared) disposePrepared(this.prepared); const { cv, image } = this; this.prepared = cv && image ? prepareImage(cv, image) : null; this.threshold = cv && this.prepared ? preparedThreshold(cv, this.prepared) : DEFAULT_THRESHOLD; diff --git a/src/lib/detect.ts b/src/lib/detect.ts index e1ef922..2e9ed8a 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -2,8 +2,15 @@ import type { Mat } from '@techstark/opencv-js'; import type { CV } from './opencv-loader'; import type { DetectParams, Point, RasterImage, Shape } from './types'; -/** Blurred, lighting-corrected grayscale plus the background brightness. Caller deletes `gray`. */ -export type PreparedImage = { gray: Mat; background: number }; +/** Per-photo detection inputs. Owns WASM memory: release with `disposePrepared`. */ +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. */ 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(); try { 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); - return { gray, background: flattenLighting(gray) }; + return { gray, background: flattenLighting(gray), texture, textureRadius: (window - 1) / 2 + 2 }; } finally { 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 * 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 { return preparedThreshold(cv, prepared); } finally { - prepared.gray.delete(); + disposePrepared(prepared); } } @@ -180,7 +213,7 @@ export function detect(cv: CV, image: RasterImage, params: DetectParams): Shape try { return detectPrepared(cv, prepared, params); } finally { - prepared.gray.delete(); + disposePrepared(prepared); } } @@ -210,6 +243,7 @@ export function detectPrepared(cv: CV, prepared: PreparedImage, params: DetectPa const lab = labels.data32S; const m = mask.data; 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); return traceMask(cv, mask, params.marginPx); } 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; 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(); + } +} diff --git a/tests/detect.test.ts b/tests/detect.test.ts index bebc585..e519685 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -1,5 +1,5 @@ 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 { loadOpenCV, type CV } from '../src/lib/opencv-loader'; import type { DetectParams } from '../src/lib/types'; @@ -126,7 +126,27 @@ describe('detect', () => { expect(reused.holes).toHaveLength(direct.holes.length); } } 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); + }); }); diff --git a/tests/fixtures/manifest.json b/tests/fixtures/manifest.json index ecbebb9..28207cf 100644 --- a/tests/fixtures/manifest.json +++ b/tests/fixtures/manifest.json @@ -4,7 +4,7 @@ "threshold": null, "seed": null, "pxPerMm": 1, - "expected": { "widthMm": 1714, "heightMm": 675 }, - "toleranceMm": 60 + "expected": { "widthMm": 1712, "heightMm": 675, "holes": 0 }, + "toleranceMm": 20 } ]