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 { 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
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Vendored
+2
-2
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user