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:
1 parent
077c3cc31d
commit
e925ce66d5
4 files changed
+112
-11
No files matched your search
@@ -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;
|
||||
|
||||
+86
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+2
-2
@@ -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
|
||||
}
|
||||
]
|
||||
Reference in new issue
Block a user