feat(gate-b): Gate B mask + BDFT + length axis (#21–#23) #31
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