feat: add OpenCV loader, Otsu threshold and margin-aware mask tracing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d597e856e
commit
5e4eebaaa2
5 files changed
+231
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import type { Mat } from '@techstark/opencv-js';
|
||||
import type { CV } from './opencv-loader';
|
||||
import type { Point, RasterImage, Shape } from './types';
|
||||
|
||||
/** Blurred grayscale; shared by thresholding and Otsu so both see the same pixels. */
|
||||
function toGray(cv: CV, image: RasterImage): Mat {
|
||||
const src = cv.matFromImageData(image as unknown as ImageData);
|
||||
const gray = new cv.Mat();
|
||||
try {
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
|
||||
cv.GaussianBlur(gray, gray, new cv.Size(5, 5), 0);
|
||||
return gray;
|
||||
} finally {
|
||||
src.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export function otsuThreshold(cv: CV, image: RasterImage): number {
|
||||
const gray = toGray(cv, image);
|
||||
const out = new cv.Mat();
|
||||
try {
|
||||
return Math.round(cv.threshold(gray, out, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU));
|
||||
} finally {
|
||||
gray.delete();
|
||||
out.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export function traceMask(cv: CV, mask: Mat, marginPx: number): Shape | null {
|
||||
const radius = Math.round(Math.abs(marginPx));
|
||||
// Padding keeps dilation from being clipped and contours off the image edge.
|
||||
const pad = radius + 2;
|
||||
const padded = new cv.Mat();
|
||||
const contours = new cv.MatVector();
|
||||
const hierarchy = new cv.Mat();
|
||||
let kernel: Mat | null = null;
|
||||
try {
|
||||
cv.copyMakeBorder(mask, padded, pad, pad, pad, pad, cv.BORDER_CONSTANT, new cv.Scalar(0));
|
||||
if (radius > 0) {
|
||||
kernel = cv.getStructuringElement(cv.MORPH_ELLIPSE, new cv.Size(2 * radius + 1, 2 * radius + 1));
|
||||
if (marginPx < 0) cv.erode(padded, padded, kernel);
|
||||
else cv.dilate(padded, padded, kernel);
|
||||
}
|
||||
cv.findContours(padded, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
|
||||
|
||||
const count = contours.size();
|
||||
const h = hierarchy.data32S; // per contour: [next, prev, firstChild, parent]
|
||||
let best = -1;
|
||||
let bestArea = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (h[i * 4 + 3] !== -1) continue;
|
||||
const c = contours.get(i);
|
||||
const area = cv.contourArea(c);
|
||||
c.delete();
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
if (best === -1) return null;
|
||||
|
||||
const ring = (i: number): Point[] => {
|
||||
const c = contours.get(i);
|
||||
const d = c.data32S;
|
||||
const pts: Point[] = [];
|
||||
for (let k = 0; k < d.length; k += 2) pts.push({ x: d[k] - pad, y: d[k + 1] - pad });
|
||||
c.delete();
|
||||
return pts;
|
||||
};
|
||||
const holes: Point[][] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (h[i * 4 + 3] === best) holes.push(ring(i));
|
||||
}
|
||||
return { outer: ring(best), holes };
|
||||
} finally {
|
||||
padded.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
kernel?.delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type CV = typeof import('@techstark/opencv-js');
|
||||
|
||||
let loading: Promise<{ cv: CV }> | null = null;
|
||||
|
||||
// Wrapped in an object: the cv module is a thenable, so resolving with it directly never settles.
|
||||
export function loadOpenCV(): Promise<{ cv: CV }> {
|
||||
loading ??= (async () => {
|
||||
const mod = (await import('@techstark/opencv-js')) as unknown as { default: CV };
|
||||
const cv = mod.default;
|
||||
if (!cv.Mat) {
|
||||
await new Promise<void>((resolve) => {
|
||||
(cv as unknown as { onRuntimeInitialized: () => void }).onRuntimeInitialized = () => resolve();
|
||||
});
|
||||
}
|
||||
return { cv };
|
||||
})();
|
||||
return loading;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Mat } from '@techstark/opencv-js';
|
||||
import type { CV } from '../../src/lib/opencv-loader';
|
||||
import type { RasterImage } from '../../src/lib/types';
|
||||
|
||||
/** Grayscale RGBA image where `paint(x, y)` returns the brightness 0–255. */
|
||||
export function grayImage(width: number, height: number, paint: (x: number, y: number) => number): RasterImage {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
const v = paint(x, y);
|
||||
data[i] = data[i + 1] = data[i + 2] = v;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
/** CV_8UC1 mask with 255 where `inside(x, y)`. Caller must delete it. */
|
||||
export function maskFrom(cv: CV, width: number, height: number, inside: (x: number, y: number) => boolean): Mat {
|
||||
const mask = cv.Mat.zeros(height, width, cv.CV_8UC1) as Mat;
|
||||
const data = mask.data;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (inside(x, y)) data[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export const inRect = (x: number, y: number, x0: number, y0: number, x1: number, y1: number) =>
|
||||
x >= x0 && x < x1 && y >= y0 && y < y1;
|
||||
|
||||
export const inCircle = (x: number, y: number, cx: number, cy: number, r: number) =>
|
||||
(x - cx) ** 2 + (y - cy) ** 2 <= r * r;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Mat } from '@techstark/opencv-js';
|
||||
import { otsuThreshold, traceMask } from '../src/lib/detect';
|
||||
import { bounds } from '../src/lib/geometry';
|
||||
import { loadOpenCV, type CV } from '../src/lib/opencv-loader';
|
||||
import { grayImage, inCircle, inRect, maskFrom } from './helpers/images';
|
||||
|
||||
let cv: CV;
|
||||
const mats: Mat[] = [];
|
||||
beforeAll(async () => {
|
||||
({ cv } = await loadOpenCV());
|
||||
});
|
||||
afterEach(() => {
|
||||
mats.splice(0).forEach((m) => m.delete());
|
||||
});
|
||||
|
||||
/** 200x160 plate: rect x 40..159, y 30..129 with a round hole r=15 at (100,80). */
|
||||
function plate(): Mat {
|
||||
const m = maskFrom(cv, 200, 160, (x, y) => inRect(x, y, 40, 30, 160, 130) && !inCircle(x, y, 100, 80, 15));
|
||||
mats.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
describe('loadOpenCV', () => {
|
||||
it('resolves to the same cv instance every time', async () => {
|
||||
const again = await loadOpenCV();
|
||||
expect(again.cv).toBe(cv);
|
||||
expect(typeof cv.Mat).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('traceMask', () => {
|
||||
it('returns the outer contour and the hole', () => {
|
||||
const shape = traceMask(cv, plate(), 0)!;
|
||||
const b = bounds(shape.outer);
|
||||
expect(Math.abs(b.minX - 40)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(b.maxX - 159)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(b.minY - 30)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(b.maxY - 129)).toBeLessThanOrEqual(2);
|
||||
expect(shape.holes).toHaveLength(1);
|
||||
const h = bounds(shape.holes[0]);
|
||||
expect(h.minX).toBeGreaterThan(82);
|
||||
expect(h.maxX).toBeLessThan(118);
|
||||
});
|
||||
|
||||
it('shrinks the outer edge and grows holes for a negative margin', () => {
|
||||
const shape = traceMask(cv, plate(), -5)!;
|
||||
const b = bounds(shape.outer);
|
||||
expect(Math.abs(b.minX - 45)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(b.maxX - 154)).toBeLessThanOrEqual(2);
|
||||
const h = bounds(shape.holes[0]);
|
||||
expect(Math.abs(h.minX - 79)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(h.maxX - 121)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('grows the outer edge for a positive margin, even past the image border', () => {
|
||||
const shape = traceMask(cv, plate(), 5)!;
|
||||
expect(Math.abs(bounds(shape.outer).minX - 35)).toBeLessThanOrEqual(2);
|
||||
|
||||
const edge = maskFrom(cv, 100, 100, (x, y) => inRect(x, y, 0, 20, 60, 80));
|
||||
mats.push(edge);
|
||||
expect(bounds(traceMask(cv, edge, 5)!.outer).minX).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('keeps the larger part when erosion splits the piece in two', () => {
|
||||
// 60x80 block and 40x40 block joined by a 7 px bridge; an 11 px erosion cuts the bridge.
|
||||
const m = maskFrom(cv, 200, 120, (x, y) =>
|
||||
inRect(x, y, 20, 20, 80, 100) || inRect(x, y, 120, 40, 160, 80) || inRect(x, y, 80, 56, 120, 63));
|
||||
mats.push(m);
|
||||
const b = bounds(traceMask(cv, m, -5)!.outer);
|
||||
expect(b.maxX).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('returns null when the margin removes the piece entirely', () => {
|
||||
const m = maskFrom(cv, 50, 50, (x, y) => inRect(x, y, 20, 20, 30, 30));
|
||||
mats.push(m);
|
||||
expect(traceMask(cv, m, -8)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not modify the input mask', () => {
|
||||
const m = plate();
|
||||
const before = cv.countNonZero(m);
|
||||
traceMask(cv, m, -5);
|
||||
expect(cv.countNonZero(m)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('otsuThreshold', () => {
|
||||
it('lands between background and piece brightness', () => {
|
||||
const image = grayImage(200, 160, (x, y) => (inRect(x, y, 40, 30, 160, 130) ? 220 : 30));
|
||||
const t = otsuThreshold(cv, image);
|
||||
expect(t).toBeGreaterThan(30);
|
||||
expect(t).toBeLessThan(220);
|
||||
});
|
||||
});
|
||||
@@ -11,5 +11,7 @@ export default defineConfig({
|
||||
include: ['tests/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 60000,
|
||||
// OpenCV's CJS export is a thenable; copying it onto the namespace makes `import()` never settle.
|
||||
deps: { interopDefault: false },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user