feat(#21): Gate B green/black sheet mask (TV-1, TV-2)
Pure client modules for TECH-SPEC sheet detect, chroma-key / black threshold with border flood-fill, four knobs (live re-run), and mask confirm hooks. Synthetic fixtures. U2Net never invoked. TDD: red evidence in test-results/tv1-tv2-red.txt, then minimum impl. Results: TV-1 | pass | test-results/tv1-tv2-green.txt TV-2 | pass | test-results/tv1-tv2-green.txt
This commit is contained in:
Generated
+1362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "slabuploader-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^2.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { RgbaImage } from "../../lib/pipeline/types.js";
|
||||||
|
|
||||||
|
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||||
|
const ss = s / 100;
|
||||||
|
const vv = v / 100;
|
||||||
|
const c = vv * ss;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = vv - c;
|
||||||
|
let rp = 0;
|
||||||
|
let gp = 0;
|
||||||
|
let bp = 0;
|
||||||
|
if (h < 60) {
|
||||||
|
rp = c;
|
||||||
|
gp = x;
|
||||||
|
} else if (h < 120) {
|
||||||
|
rp = x;
|
||||||
|
gp = c;
|
||||||
|
} else if (h < 180) {
|
||||||
|
gp = c;
|
||||||
|
bp = x;
|
||||||
|
} else if (h < 240) {
|
||||||
|
gp = x;
|
||||||
|
bp = c;
|
||||||
|
} else if (h < 300) {
|
||||||
|
rp = x;
|
||||||
|
bp = c;
|
||||||
|
} else {
|
||||||
|
rp = c;
|
||||||
|
bp = x;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
Math.round((rp + m) * 255),
|
||||||
|
Math.round((gp + m) * 255),
|
||||||
|
Math.round((bp + m) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRect(
|
||||||
|
img: RgbaImage,
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
rgb: [number, number, number],
|
||||||
|
): void {
|
||||||
|
const [r, g, b] = rgb;
|
||||||
|
for (let y = y0; y < y1; y++) {
|
||||||
|
for (let x = x0; x < x1; x++) {
|
||||||
|
const i = (y * img.width + x) * 4;
|
||||||
|
img.data[i] = r;
|
||||||
|
img.data[i + 1] = g;
|
||||||
|
img.data[i + 2] = b;
|
||||||
|
img.data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blankImage(width: number, height: number): RgbaImage {
|
||||||
|
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TV-1 fixture: green sheet border, brown wood slab, interior green-ish patch
|
||||||
|
* that does not touch the image border.
|
||||||
|
*/
|
||||||
|
export function greenSheetFixture(size = 64): {
|
||||||
|
image: RgbaImage;
|
||||||
|
interiorGreen: { x: number; y: number };
|
||||||
|
woodSample: { x: number; y: number };
|
||||||
|
borderSample: { x: number; y: number };
|
||||||
|
} {
|
||||||
|
const image = blankImage(size, size);
|
||||||
|
const green = hsvToRgb(60, 80, 80);
|
||||||
|
const wood = hsvToRgb(30, 55, 55);
|
||||||
|
const interiorGreen = hsvToRgb(55, 70, 70);
|
||||||
|
fillRect(image, 0, 0, size, size, green);
|
||||||
|
// Wood slab inset so it does not touch border.
|
||||||
|
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||||
|
// Interior green-ish patch fully inside wood (no border contact).
|
||||||
|
fillRect(image, 28, 28, 36, 36, interiorGreen);
|
||||||
|
return {
|
||||||
|
image,
|
||||||
|
interiorGreen: { x: 32, y: 32 },
|
||||||
|
woodSample: { x: 20, y: 20 },
|
||||||
|
borderSample: { x: 2, y: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TV-2 fixture: black sheet border, mid-tone wood, dark interior wood patch
|
||||||
|
* that does not touch the border.
|
||||||
|
*/
|
||||||
|
export function blackSheetFixture(size = 64): {
|
||||||
|
image: RgbaImage;
|
||||||
|
darkInterior: { x: number; y: number };
|
||||||
|
woodSample: { x: number; y: number };
|
||||||
|
borderSample: { x: number; y: number };
|
||||||
|
} {
|
||||||
|
const image = blankImage(size, size);
|
||||||
|
const black: [number, number, number] = [8, 8, 8];
|
||||||
|
const wood: [number, number, number] = [140, 110, 80];
|
||||||
|
const darkWood: [number, number, number] = [18, 14, 10];
|
||||||
|
fillRect(image, 0, 0, size, size, black);
|
||||||
|
fillRect(image, 12, 12, size - 12, size - 12, wood);
|
||||||
|
fillRect(image, 28, 28, 36, 36, darkWood);
|
||||||
|
return {
|
||||||
|
image,
|
||||||
|
darkInterior: { x: 32, y: 32 },
|
||||||
|
woodSample: { x: 20, y: 20 },
|
||||||
|
borderSample: { x: 2, y: 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskAt(mask: Uint8Array, width: number, x: number, y: number): number {
|
||||||
|
return mask[y * width + x]!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
confirmMask,
|
||||||
|
createMaskSession,
|
||||||
|
defaultKnobs,
|
||||||
|
removeSheetBackground,
|
||||||
|
reRunMask,
|
||||||
|
requestRetake,
|
||||||
|
} from "../lib/pipeline/mask.js";
|
||||||
|
import {
|
||||||
|
blackSheetFixture,
|
||||||
|
greenSheetFixture,
|
||||||
|
maskAt,
|
||||||
|
} from "./fixtures/synthetic.js";
|
||||||
|
|
||||||
|
describe("TV-1 green-sheet chroma-key", () => {
|
||||||
|
it("keeps interior green-ish patch that does not touch the border", () => {
|
||||||
|
const { image, interiorGreen, woodSample, borderSample } = greenSheetFixture();
|
||||||
|
const knobs = { ...defaultKnobs(), sheet: "green" as const };
|
||||||
|
const result = removeSheetBackground(image, knobs);
|
||||||
|
|
||||||
|
expect(result.u2netInvoked).toBe(false);
|
||||||
|
expect(result.sheetUsed).toBe("green");
|
||||||
|
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||||
|
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
maskAt(result.mask, result.width, interiorGreen.x, interiorGreen.y),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-runs live when knobs change and still skips U2Net", () => {
|
||||||
|
const { image } = greenSheetFixture();
|
||||||
|
const first = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||||
|
const second = reRunMask(image, {
|
||||||
|
...defaultKnobs(),
|
||||||
|
sheet: "green",
|
||||||
|
sensitivity: 0.7,
|
||||||
|
edgeOffset: 1,
|
||||||
|
feather: 1,
|
||||||
|
});
|
||||||
|
expect(first.u2netInvoked).toBe(false);
|
||||||
|
expect(second.u2netInvoked).toBe(false);
|
||||||
|
expect(second.knobs.sensitivity).toBe(0.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TV-2 black-sheet threshold + border flood-fill", () => {
|
||||||
|
it("keeps dark interior wood that does not touch the border", () => {
|
||||||
|
const { image, darkInterior, woodSample, borderSample } = blackSheetFixture();
|
||||||
|
const knobs = { ...defaultKnobs(), sheet: "black" as const };
|
||||||
|
const result = removeSheetBackground(image, knobs);
|
||||||
|
|
||||||
|
expect(result.u2netInvoked).toBe(false);
|
||||||
|
expect(result.sheetUsed).toBe("black");
|
||||||
|
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||||
|
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flood-fill removes only border-connected dark pixels", () => {
|
||||||
|
const { image, darkInterior, borderSample } = blackSheetFixture();
|
||||||
|
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "black" });
|
||||||
|
// Border sheet gone; isolated dark island kept.
|
||||||
|
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
|
||||||
|
expect(
|
||||||
|
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mask confirm hooks (pure)", () => {
|
||||||
|
it("starts pending, confirm and retake transition without DOM", () => {
|
||||||
|
const { image } = greenSheetFixture();
|
||||||
|
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
|
||||||
|
const session = createMaskSession(result);
|
||||||
|
expect(session.state).toBe("pending");
|
||||||
|
expect(confirmMask(session).state).toBe("confirmed");
|
||||||
|
expect(requestRetake(session).state).toBe("retake");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** TECH-SPEC section 7 factory and code constants for BG removal. */
|
||||||
|
export const FILL_TARGET = 0.8;
|
||||||
|
export const ASPECT_W = 3;
|
||||||
|
export const ASPECT_H = 4;
|
||||||
|
export const OUTPUT_PX_MIN = 1600;
|
||||||
|
export const WIDTH_SAMPLE_IN = 6;
|
||||||
|
export const INCH_STEP = 0.125;
|
||||||
|
export const AREA_DECIMALS = 2;
|
||||||
|
export const BDFT_DECIMALS = 2;
|
||||||
|
export const PRICE_DECIMALS = 2;
|
||||||
|
|
||||||
|
export const SHEET_DEFAULT = "auto" as const;
|
||||||
|
export const GREEN_HUE_LO = 35;
|
||||||
|
export const GREEN_HUE_HI = 85;
|
||||||
|
export const GREEN_SAT_MIN = 40;
|
||||||
|
export const GREEN_VAL_MIN = 40;
|
||||||
|
export const BLACK_THRESH = 30;
|
||||||
|
|
||||||
|
export const SENSITIVITY_DEFAULT = 0.5;
|
||||||
|
export const EDGE_OFFSET_DEFAULT = 0;
|
||||||
|
export const FEATHER_DEFAULT = 0;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./constants.js";
|
||||||
|
export * from "./types.js";
|
||||||
|
export * from "./mask.js";
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import type {
|
||||||
|
KnobSettings,
|
||||||
|
MaskBuffer,
|
||||||
|
MaskResult,
|
||||||
|
MaskSession,
|
||||||
|
RgbaImage,
|
||||||
|
ResolvedSheet,
|
||||||
|
} from "./types.js";
|
||||||
|
import {
|
||||||
|
BLACK_THRESH,
|
||||||
|
EDGE_OFFSET_DEFAULT,
|
||||||
|
FEATHER_DEFAULT,
|
||||||
|
GREEN_HUE_HI,
|
||||||
|
GREEN_HUE_LO,
|
||||||
|
GREEN_SAT_MIN,
|
||||||
|
GREEN_VAL_MIN,
|
||||||
|
SENSITIVITY_DEFAULT,
|
||||||
|
SHEET_DEFAULT,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export function defaultKnobs(): KnobSettings {
|
||||||
|
return {
|
||||||
|
sheet: SHEET_DEFAULT,
|
||||||
|
sensitivity: SENSITIVITY_DEFAULT,
|
||||||
|
edgeOffset: EDGE_OFFSET_DEFAULT,
|
||||||
|
feather: FEATHER_DEFAULT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
||||||
|
const rn = r / 255;
|
||||||
|
const gn = g / 255;
|
||||||
|
const bn = b / 255;
|
||||||
|
const max = Math.max(rn, gn, bn);
|
||||||
|
const min = Math.min(rn, gn, bn);
|
||||||
|
const d = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
if (max === rn) h = ((gn - bn) / d) % 6;
|
||||||
|
else if (max === gn) h = (bn - rn) / d + 2;
|
||||||
|
else h = (rn - gn) / d + 4;
|
||||||
|
h *= 60;
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
const s = max === 0 ? 0 : (d / max) * 100;
|
||||||
|
const v = max * 100;
|
||||||
|
return { h, s, v };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelRgb(image: RgbaImage, x: number, y: number): [number, number, number] {
|
||||||
|
const i = (y * image.width + x) * 4;
|
||||||
|
return [image.data[i]!, image.data[i + 1]!, image.data[i + 2]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TECH-SPEC section 3.1 auto-detect from 2-5% border strips. */
|
||||||
|
export function detectSheet(image: RgbaImage): ResolvedSheet | "ambiguous" {
|
||||||
|
const strip = Math.max(1, Math.floor(Math.min(image.width, image.height) * 0.03));
|
||||||
|
let hSum = 0;
|
||||||
|
let sSum = 0;
|
||||||
|
let vSum = 0;
|
||||||
|
let n = 0;
|
||||||
|
const sample = (x: number, y: number) => {
|
||||||
|
const [r, g, b] = pixelRgb(image, x, y);
|
||||||
|
const hsv = rgbToHsv(r, g, b);
|
||||||
|
hSum += hsv.h;
|
||||||
|
sSum += hsv.s;
|
||||||
|
vSum += hsv.v;
|
||||||
|
n += 1;
|
||||||
|
};
|
||||||
|
for (let x = 0; x < image.width; x++) {
|
||||||
|
for (let t = 0; t < strip; t++) {
|
||||||
|
sample(x, t);
|
||||||
|
sample(x, image.height - 1 - t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let y = strip; y < image.height - strip; y++) {
|
||||||
|
for (let t = 0; t < strip; t++) {
|
||||||
|
sample(t, y);
|
||||||
|
sample(image.width - 1 - t, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n === 0) return "ambiguous";
|
||||||
|
const h = hSum / n;
|
||||||
|
const s = sSum / n;
|
||||||
|
const v = vSum / n;
|
||||||
|
if (h >= GREEN_HUE_LO && h <= GREEN_HUE_HI && s >= GREEN_SAT_MIN) return "green";
|
||||||
|
if (v <= BLACK_THRESH && s <= 20) return "black";
|
||||||
|
return "ambiguous";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSheet(image: RgbaImage, knobs: KnobSettings): ResolvedSheet {
|
||||||
|
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
|
||||||
|
const detected = detectSheet(image);
|
||||||
|
if (detected === "ambiguous") return "green";
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function greenSheetCandidate(
|
||||||
|
r: number,
|
||||||
|
g: number,
|
||||||
|
b: number,
|
||||||
|
sensitivity: number,
|
||||||
|
): boolean {
|
||||||
|
const { h, s, v } = rgbToHsv(r, g, b);
|
||||||
|
const widen = (sensitivity - 0.5) * 20;
|
||||||
|
const hueLo = GREEN_HUE_LO - widen;
|
||||||
|
const hueHi = GREEN_HUE_HI + widen;
|
||||||
|
const satMin = Math.max(5, GREEN_SAT_MIN - widen);
|
||||||
|
const valMin = Math.max(5, GREEN_VAL_MIN - widen);
|
||||||
|
return h >= hueLo && h <= hueHi && s >= satMin && v >= valMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blackSheetCandidate(r: number, g: number, b: number, sensitivity: number): boolean {
|
||||||
|
const { s, v } = rgbToHsv(r, g, b);
|
||||||
|
const loosen = (sensitivity - 0.5) * 20;
|
||||||
|
const thresh = BLACK_THRESH + loosen;
|
||||||
|
return v <= thresh && s <= 20 + Math.max(0, loosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSheetCandidates(
|
||||||
|
image: RgbaImage,
|
||||||
|
sheet: ResolvedSheet,
|
||||||
|
sensitivity: number,
|
||||||
|
): Uint8Array {
|
||||||
|
const out = new Uint8Array(image.width * image.height);
|
||||||
|
for (let y = 0; y < image.height; y++) {
|
||||||
|
for (let x = 0; x < image.width; x++) {
|
||||||
|
const [r, g, b] = pixelRgb(image, x, y);
|
||||||
|
const isSheet =
|
||||||
|
sheet === "green"
|
||||||
|
? greenSheetCandidate(r, g, b, sensitivity)
|
||||||
|
: blackSheetCandidate(r, g, b, sensitivity);
|
||||||
|
out[y * image.width + x] = isSheet ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flood-fill sheet candidates reachable from the image border. */
|
||||||
|
function borderConnectedSheet(candidates: Uint8Array, width: number, height: number): Uint8Array {
|
||||||
|
const removed = new Uint8Array(width * height);
|
||||||
|
const stack: number[] = [];
|
||||||
|
const push = (x: number, y: number) => {
|
||||||
|
const i = y * width + x;
|
||||||
|
if (candidates[i] !== 1 || removed[i] === 1) return;
|
||||||
|
removed[i] = 1;
|
||||||
|
stack.push(i);
|
||||||
|
};
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
push(x, 0);
|
||||||
|
push(x, height - 1);
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
push(0, y);
|
||||||
|
push(width - 1, y);
|
||||||
|
}
|
||||||
|
while (stack.length) {
|
||||||
|
const i = stack.pop()!;
|
||||||
|
const x = i % width;
|
||||||
|
const y = (i / width) | 0;
|
||||||
|
if (x > 0) push(x - 1, y);
|
||||||
|
if (x + 1 < width) push(x + 1, y);
|
||||||
|
if (y > 0) push(x, y - 1);
|
||||||
|
if (y + 1 < height) push(x, y + 1);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function morphClose(mask: MaskBuffer, width: number, height: number): MaskBuffer {
|
||||||
|
const dilate = new Uint8Array(mask.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let max = 0;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const xx = x + dx;
|
||||||
|
const yy = y + dy;
|
||||||
|
if (xx < 0 || yy < 0 || xx >= width || yy >= height) continue;
|
||||||
|
const v = mask[yy * width + xx]!;
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dilate[y * width + x] = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const erode = new Uint8Array(mask.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let min = 255;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const xx = x + dx;
|
||||||
|
const yy = y + dy;
|
||||||
|
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
|
||||||
|
min = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = dilate[yy * width + xx]!;
|
||||||
|
if (v < min) min = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
erode[y * width + x] = min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return erode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEdgeOffset(
|
||||||
|
mask: MaskBuffer,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
offset: number,
|
||||||
|
): MaskBuffer {
|
||||||
|
if (offset === 0) return mask;
|
||||||
|
const steps = Math.abs(Math.round(offset));
|
||||||
|
let current = mask;
|
||||||
|
for (let s = 0; s < steps; s++) {
|
||||||
|
const next = new Uint8Array(current.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
let agg = offset > 0 ? 255 : 0;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
const xx = x + dx;
|
||||||
|
const yy = y + dy;
|
||||||
|
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
|
||||||
|
if (offset > 0) agg = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = current[yy * width + xx]!;
|
||||||
|
if (offset > 0) {
|
||||||
|
if (v < agg) agg = v;
|
||||||
|
} else if (v > agg) {
|
||||||
|
agg = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next[y * width + x] = agg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFeather(
|
||||||
|
mask: MaskBuffer,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
feather: number,
|
||||||
|
): MaskBuffer {
|
||||||
|
const radius = Math.max(0, Math.round(feather));
|
||||||
|
if (radius === 0) return mask;
|
||||||
|
const out = new Uint8Array(mask.length);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const i = y * width + x;
|
||||||
|
if (mask[i]! === 0) {
|
||||||
|
out[i] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let minDist = radius + 1;
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
const xx = x + dx;
|
||||||
|
const yy = y + dy;
|
||||||
|
if (xx < 0 || yy < 0 || xx >= width || yy >= height || mask[yy * width + xx]! === 0) {
|
||||||
|
const d = Math.hypot(dx, dy);
|
||||||
|
if (d < minDist) minDist = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minDist > radius) out[i] = 255;
|
||||||
|
else out[i] = Math.round(255 * (minDist / (radius + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSheetBackground(
|
||||||
|
image: RgbaImage,
|
||||||
|
knobs: KnobSettings = defaultKnobs(),
|
||||||
|
): MaskResult {
|
||||||
|
const sheetUsed = resolveSheet(image, knobs);
|
||||||
|
const candidates = buildSheetCandidates(image, sheetUsed, knobs.sensitivity);
|
||||||
|
const removed = borderConnectedSheet(candidates, image.width, image.height);
|
||||||
|
let mask: MaskBuffer = new Uint8Array(image.width * image.height);
|
||||||
|
for (let i = 0; i < mask.length; i++) {
|
||||||
|
mask[i] = removed[i] === 1 ? 0 : 255;
|
||||||
|
}
|
||||||
|
mask = morphClose(mask, image.width, image.height);
|
||||||
|
mask = applyEdgeOffset(mask, image.width, image.height, knobs.edgeOffset);
|
||||||
|
mask = applyFeather(mask, image.width, image.height, knobs.feather);
|
||||||
|
return {
|
||||||
|
mask,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
sheetUsed,
|
||||||
|
u2netInvoked: false,
|
||||||
|
knobs: { ...knobs },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reRunMask(image: RgbaImage, knobs: KnobSettings): MaskResult {
|
||||||
|
return removeSheetBackground(image, knobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMaskSession(result: MaskResult): MaskSession {
|
||||||
|
return { result, state: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmMask(session: MaskSession): MaskSession {
|
||||||
|
return { ...session, state: "confirmed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestRetake(session: MaskSession): MaskSession {
|
||||||
|
return { ...session, state: "retake" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
GREEN_HUE_LO,
|
||||||
|
GREEN_HUE_HI,
|
||||||
|
GREEN_SAT_MIN,
|
||||||
|
GREEN_VAL_MIN,
|
||||||
|
BLACK_THRESH,
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type SheetMode = "auto" | "green" | "black";
|
||||||
|
export type ResolvedSheet = "green" | "black";
|
||||||
|
|
||||||
|
export interface KnobSettings {
|
||||||
|
sheet: SheetMode;
|
||||||
|
/** 0..1 — looser sheet detection at higher values. */
|
||||||
|
sensitivity: number;
|
||||||
|
/** Signed pixels: positive pulls mask inward onto wood. */
|
||||||
|
edgeOffset: number;
|
||||||
|
/** Soft alpha blend width in pixels. */
|
||||||
|
feather: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RgbaImage {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Row-major RGBA, length width*height*4. */
|
||||||
|
data: Uint8ClampedArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 = removed background, 255 = kept slab (or intermediate for feather). */
|
||||||
|
export type MaskBuffer = Uint8Array;
|
||||||
|
|
||||||
|
export type MaskConfirmState = "pending" | "confirmed" | "retake";
|
||||||
|
|
||||||
|
export interface MaskResult {
|
||||||
|
mask: MaskBuffer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sheetUsed: ResolvedSheet;
|
||||||
|
/** POC contract: always false (U2Net deferred). */
|
||||||
|
u2netInvoked: false;
|
||||||
|
knobs: KnobSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskSession {
|
||||||
|
result: MaskResult;
|
||||||
|
state: MaskConfirmState;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vitest/globals"],
|
||||||
|
"lib": ["ES2022"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||||
|
|
||||||
|
✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 34ms
|
||||||
|
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 5 passed (5)
|
||||||
|
Start at 05:53:31
|
||||||
|
Duration 456ms (transform 81ms, setup 0ms, collect 85ms, tests 34ms, environment 0ms, prepare 64ms)
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
|
||||||
|
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
|
||||||
|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts (5 tests | 5 failed) 14ms
|
||||||
|
× TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border 8ms
|
||||||
|
→ removeSheetBackground not implemented
|
||||||
|
× TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net 1ms
|
||||||
|
→ removeSheetBackground not implemented
|
||||||
|
× TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border 1ms
|
||||||
|
→ removeSheetBackground not implemented
|
||||||
|
× TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels 1ms
|
||||||
|
→ removeSheetBackground not implemented
|
||||||
|
× mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM 2ms
|
||||||
|
→ removeSheetBackground not implemented
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
|
||||||
|
|
||||||
|
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border
|
||||||
|
Error: removeSheetBackground not implemented
|
||||||
|
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||||
|
29| _knobs: KnobSettings = defaultKnobs(),
|
||||||
|
30| ): MaskResult {
|
||||||
|
31| throw new Error("removeSheetBackground not implemented");
|
||||||
|
| ^
|
||||||
|
32| }
|
||||||
|
33|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts:20:20
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯
|
||||||
|
|
||||||
|
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net
|
||||||
|
Error: removeSheetBackground not implemented
|
||||||
|
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||||
|
29| _knobs: KnobSettings = defaultKnobs(),
|
||||||
|
30| ): MaskResult {
|
||||||
|
31| throw new Error("removeSheetBackground not implemented");
|
||||||
|
| ^
|
||||||
|
32| }
|
||||||
|
33|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts:33:19
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯
|
||||||
|
|
||||||
|
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border
|
||||||
|
Error: removeSheetBackground not implemented
|
||||||
|
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||||
|
29| _knobs: KnobSettings = defaultKnobs(),
|
||||||
|
30| ): MaskResult {
|
||||||
|
31| throw new Error("removeSheetBackground not implemented");
|
||||||
|
| ^
|
||||||
|
32| }
|
||||||
|
33|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts:51:20
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯
|
||||||
|
|
||||||
|
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels
|
||||||
|
Error: removeSheetBackground not implemented
|
||||||
|
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||||
|
29| _knobs: KnobSettings = defaultKnobs(),
|
||||||
|
30| ): MaskResult {
|
||||||
|
31| throw new Error("removeSheetBackground not implemented");
|
||||||
|
| ^
|
||||||
|
32| }
|
||||||
|
33|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts:64:20
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯
|
||||||
|
|
||||||
|
FAIL src/__tests__/tv1-tv2-mask.test.ts > mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM
|
||||||
|
Error: removeSheetBackground not implemented
|
||||||
|
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
|
||||||
|
29| _knobs: KnobSettings = defaultKnobs(),
|
||||||
|
30| ): MaskResult {
|
||||||
|
31| throw new Error("removeSheetBackground not implemented");
|
||||||
|
| ^
|
||||||
|
32| }
|
||||||
|
33|
|
||||||
|
❯ src/__tests__/tv1-tv2-mask.test.ts:76:20
|
||||||
|
|
||||||
|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯
|
||||||
|
|
||||||
|
Test Files 1 failed (1)
|
||||||
|
Tests 5 failed (5)
|
||||||
|
Start at 05:52:50
|
||||||
|
Duration 383ms (transform 68ms, setup 0ms, collect 70ms, tests 14ms, environment 0ms, prepare 67ms)
|
||||||
|
|
||||||
Reference in New Issue
Block a user