react-native-mask-segment-c.../__tests__/wallTextureSplit.test.ts

475 lines
13 KiB
TypeScript
Raw Normal View History

jest.mock('../src/utils/opencvAdapter', () => ({
__esModule: true,
default: {},
}));
import {
resetMaskSegmentRuntimeConfig,
setMaskSegmentRuntimeConfig,
} from '../src/utils/maskSegmentRuntime';
import type { SegmentMaskResult } from '../src/utils/maskSegmentation';
import {
buildPickMapAfterWallSplit,
dilatePickBuffer1px,
patchPickMapForManualWallSplit,
splitWallRegionsByTexture,
absorbSmallWallGapsForLassoPolygons,
WALL_SUB_LABEL_NONE,
} from '../src/utils/wallTextureSplit';
const WALL_IDX = 3;
const IGNORE = 255;
function buildSyntheticWallResult(
cols: number,
rows: number,
): SegmentMaskResult {
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(WALL_IDX);
const baseboardBinary = new Uint8Array(pixelCount);
const pick = new Uint8Array(pixelCount);
pick.fill(1);
return {
regions: [
{
id: 0,
name: 'wall',
hex: '#4363D8',
color: { b: 216, g: 99, r: 67 },
polygons: [
[
{ x: 0, y: 0 },
{ x: cols, y: 0 },
{ x: cols, y: rows },
{ x: 0, y: rows },
],
],
bbox: { x: 0, y: 0, w: cols, h: rows },
area: pixelCount,
},
],
pickMap: { buffer: pick, cols, rows },
labels,
baseboardBinary,
segCols: cols,
segRows: rows,
};
}
function buildSplitOrigin(
cols: number,
rows: number,
leftBgr: [number, number, number],
rightBgr: [number, number, number],
): Uint8Array {
const buffer = new Uint8Array(cols * rows * 3);
const mid = Math.floor(cols / 2);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const [b, g, r] = x < mid ? leftBgr : rightBgr;
const i = (y * cols + x) * 3;
buffer[i] = b;
buffer[i + 1] = g;
buffer[i + 2] = r;
}
}
return buffer;
}
function pickRegionIdAt(
pick: Uint8Array,
cols: number,
x: number,
y: number,
): number | null {
const code = pick[y * cols + x];
return code > 0 ? code - 1 : null;
}
beforeEach(() => {
resetMaskSegmentRuntimeConfig();
});
afterAll(() => {
resetMaskSegmentRuntimeConfig();
});
test('splitWalls:false leaves regions unchanged', () => {
const cols = 24;
const rows = 12;
const base = buildSyntheticWallResult(cols, rows);
const origin = buildSplitOrigin(cols, rows, [8, 8, 8], [240, 240, 240]);
setMaskSegmentRuntimeConfig({ maskConfig: { splitWalls: false } });
const result = splitWallRegionsByTexture(base, origin, cols, rows, 10);
expect(result.regions.find(r => r.name === 'wall')).toBeTruthy();
expect(result.regions.some(r => /^wall-\d+$/.test(r.name))).toBe(false);
});
test('white wall and blue wall split into separate regions', () => {
const cols = 32;
const rows = 16;
const base = buildSyntheticWallResult(cols, rows);
const origin = buildSplitOrigin(cols, rows, [245, 245, 245], [180, 120, 60]);
setMaskSegmentRuntimeConfig({
maskConfig: { splitWalls: true },
});
const result = splitWallRegionsByTexture(base, origin, cols, rows, 10);
const wallSubs = result.regions.filter(r => /^wall-\d+$/.test(r.name));
expect(wallSubs.length).toBeGreaterThanOrEqual(2);
const pick = result.pickMap.buffer;
const leftId = pickRegionIdAt(pick, cols, 4, rows >> 1);
const rightId = pickRegionIdAt(pick, cols, cols - 4, rows >> 1);
expect(leftId).not.toBeNull();
expect(rightId).not.toBeNull();
expect(leftId).not.toBe(rightId);
});
test('splitWalls:true splits wall into wall-1 and wall-2 by chroma', () => {
const cols = 24;
const rows = 12;
const base = buildSyntheticWallResult(cols, rows);
// Left blue, right warm (obvious chroma difference)
const origin = buildSplitOrigin(cols, rows, [220, 50, 30], [50, 200, 240]);
setMaskSegmentRuntimeConfig({
maskConfig: {
splitWalls: true,
splitWallsMinAreaRatio: 0.001,
splitWallsColorDistSq: 400,
},
});
const result = splitWallRegionsByTexture(base, origin, cols, rows, 10);
const wallSubs = result.regions.filter(r => /^wall-\d+$/.test(r.name));
expect(wallSubs.length).toBeGreaterThanOrEqual(2);
expect(result.regions.some(r => r.name === 'wall')).toBe(false);
expect(result.wallSubLabels).toBeDefined();
const pick = result.pickMap.buffer;
const leftId = pickRegionIdAt(pick, cols, 4, rows >> 1);
const rightId = pickRegionIdAt(pick, cols, cols - 4, rows >> 1);
expect(leftId).not.toBeNull();
expect(rightId).not.toBeNull();
expect(leftId).not.toBe(rightId);
});
test('same-color wall with lighting gradient stays one region', () => {
const cols = 32;
const rows = 16;
const base = buildSyntheticWallResult(cols, rows);
const origin = new Uint8Array(cols * rows * 3);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const t = x / Math.max(1, cols - 1);
const i = (y * cols + x) * 3;
// Same blue wall, from dark shadow on left to bright area on right
origin[i] = Math.round(160 + t * 70);
origin[i + 1] = Math.round(70 + t * 50);
origin[i + 2] = Math.round(40 + t * 30);
}
}
setMaskSegmentRuntimeConfig({
maskConfig: { splitWalls: true },
});
const result = splitWallRegionsByTexture(base, origin, cols, rows, 10);
const wallSubs = result.regions.filter(r => /^wall-\d+$/.test(r.name));
expect(wallSubs).toHaveLength(1);
expect(wallSubs[0].name).toBe('wall-1');
});
test('manual wall split pick map keeps ceiling and wall sub-regions paintable', () => {
const cols = 20;
const rows = 10;
const CEILING_IDX = 1;
const WALL_IDX = 3;
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(CEILING_IDX);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols; x++) {
labels[y * cols + x] = WALL_IDX;
}
}
const baseboardBinary = new Uint8Array(pixelCount);
const wallSubLabels = new Uint8Array(pixelCount);
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols / 2; x++) {
wallSubLabels[y * cols + x] = 0;
}
for (let x = cols / 2; x < cols; x++) {
wallSubLabels[y * cols + x] = 1;
}
}
const indexToName = [
'door',
'ceiling',
'cabinet',
'wall',
'baseboard',
'windowFrame',
'garageDoor',
'roof',
'eave',
];
const mergedRegions = [
{ id: 0, name: 'ceiling', area: 100 },
{ id: 1, name: 'wall-1', area: 50 },
{ id: 2, name: 'wall-2', area: 50 },
];
const nameToId = new Map(mergedRegions.map(r => [r.name, r.id]));
const pickRaw = buildPickMapAfterWallSplit(
labels,
baseboardBinary,
WALL_IDX,
wallSubLabels,
indexToName,
nameToId,
cols,
rows,
);
const pick = dilatePickBuffer1px(pickRaw, cols, rows);
expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0);
expect(pickRegionIdAt(pick, cols, 3, 7)).toBe(1);
expect(pickRegionIdAt(pick, cols, 15, 7)).toBe(2);
});
test('manual wall split keeps non-wall region IDs stable', () => {
const cols = 20;
const rows = 10;
const CEILING_IDX = 1;
const WALL_IDX = 3;
const WINDOW_IDX = 5;
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(CEILING_IDX);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols; x++) {
labels[y * cols + x] = WALL_IDX;
}
}
labels[2 * cols + 18] = WINDOW_IDX;
const nonWallRegions = [
{ id: 0, name: 'ceiling', area: 100 },
{ id: 2, name: 'windowFrame', area: 20 },
];
const maxNonWallId = Math.max(...nonWallRegions.map(r => r.id));
const wallSubRegions = [
{ id: maxNonWallId + 1, name: 'wall-1', area: 50 },
{ id: maxNonWallId + 2, name: 'wall-2', area: 40 },
];
const mergedRegions = [...nonWallRegions, ...wallSubRegions];
expect(mergedRegions.find(r => r.name === 'ceiling')?.id).toBe(0);
expect(mergedRegions.find(r => r.name === 'windowFrame')?.id).toBe(2);
expect(mergedRegions.find(r => r.name === 'wall-1')?.id).toBe(3);
expect(mergedRegions.find(r => r.name === 'wall-2')?.id).toBe(4);
const indexToName = [
'door',
'ceiling',
'cabinet',
'wall',
'baseboard',
'windowFrame',
'garageDoor',
'roof',
'eave',
];
const baseboardBinary = new Uint8Array(pixelCount);
const wallSubLabels = new Uint8Array(pixelCount);
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols / 2; x++) {
wallSubLabels[y * cols + x] = 0;
}
for (let x = cols / 2; x < cols; x++) {
wallSubLabels[y * cols + x] = 1;
}
}
const nameToId = new Map(mergedRegions.map(r => [r.name, r.id]));
const pickRaw = buildPickMapAfterWallSplit(
labels,
baseboardBinary,
WALL_IDX,
wallSubLabels,
indexToName,
nameToId,
cols,
rows,
);
const pick = dilatePickBuffer1px(pickRaw, cols, rows);
expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0);
expect(pickRegionIdAt(pick, cols, 18, 2)).toBe(2);
});
test('patchPickMapForManualWallSplit preserves non-wall pick codes', () => {
const cols = 20;
const rows = 10;
const CEILING_IDX = 1;
const WALL_IDX = 3;
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(CEILING_IDX);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols; x++) {
labels[y * cols + x] = WALL_IDX;
}
}
const existingPick = new Uint8Array(pixelCount);
existingPick.fill(0);
for (let y = 0; y < 5; y++) {
for (let x = 0; x < cols; x++) {
existingPick[y * cols + x] = 1; // ceiling id 0
}
}
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols; x++) {
existingPick[y * cols + x] = 2; // old wall id 1
}
}
const wallSubLabels = new Uint8Array(pixelCount);
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
for (let y = 5; y < rows; y++) {
for (let x = 0; x < cols / 2; x++) {
wallSubLabels[y * cols + x] = 0;
}
for (let x = cols / 2; x < cols; x++) {
wallSubLabels[y * cols + x] = 1;
}
}
const baseboardBinary = new Uint8Array(pixelCount);
const nameToId = new Map([
['ceiling', 0],
['wall-1', 3],
['wall-2', 4],
]);
const patched = patchPickMapForManualWallSplit(
existingPick,
labels,
baseboardBinary,
WALL_IDX,
wallSubLabels,
nameToId,
cols,
rows,
);
expect(pickRegionIdAt(patched, cols, 10, 2)).toBe(0);
expect(pickRegionIdAt(patched, cols, 3, 7)).toBe(3);
expect(pickRegionIdAt(patched, cols, 15, 7)).toBe(4);
});
test('single-texture wall becomes wall-1 when splitWalls enabled', () => {
const cols = 16;
const rows = 8;
const base = buildSyntheticWallResult(cols, rows);
const origin = buildSplitOrigin(cols, rows, [120, 120, 120], [120, 120, 120]);
setMaskSegmentRuntimeConfig({
maskConfig: { splitWalls: true },
});
const result = splitWallRegionsByTexture(base, origin, cols, rows, 10);
const wallSubs = result.regions.filter(r => /^wall-\d+$/.test(r.name));
expect(wallSubs).toHaveLength(1);
expect(wallSubs[0].name).toBe('wall-1');
});
test('absorbSmallWallGapsForLassoPolygons merges thin unassigned wall slivers', () => {
const cols = 20;
const rows = 1;
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(WALL_IDX);
const baseboardBinary = new Uint8Array(pixelCount);
const priorAssigned = new Uint8Array(pixelCount);
priorAssigned.fill(WALL_SUB_LABEL_NONE);
const polyLabels = new Uint8Array(pixelCount);
polyLabels.fill(WALL_SUB_LABEL_NONE);
for (let x = 0; x <= 14; x++) {
polyLabels[x] = 0;
}
const areas = [15];
const bboxes = [{ x: 0, y: 0, w: 15, h: 1 }];
absorbSmallWallGapsForLassoPolygons(
polyLabels,
1,
areas,
bboxes,
labels,
baseboardBinary,
WALL_IDX,
priorAssigned,
cols,
rows,
3,
);
expect(polyLabels[15]).toBe(0);
expect(polyLabels[16]).toBe(0);
expect(polyLabels[17]).toBe(0);
expect(areas[0]).toBe(18);
});
test('absorbSmallWallGapsForLassoPolygons respects dilation radius limit', () => {
const cols = 20;
const rows = 1;
const pixelCount = cols * rows;
const labels = new Uint8Array(pixelCount);
labels.fill(WALL_IDX);
const baseboardBinary = new Uint8Array(pixelCount);
const priorAssigned = new Uint8Array(pixelCount);
priorAssigned.fill(WALL_SUB_LABEL_NONE);
const polyLabels = new Uint8Array(pixelCount);
polyLabels.fill(WALL_SUB_LABEL_NONE);
for (let x = 0; x <= 10; x++) {
polyLabels[x] = 0;
}
const areas = [11];
const bboxes = [{ x: 0, y: 0, w: 11, h: 1 }];
absorbSmallWallGapsForLassoPolygons(
polyLabels,
1,
areas,
bboxes,
labels,
baseboardBinary,
WALL_IDX,
priorAssigned,
cols,
rows,
3,
);
expect(polyLabels[11]).toBe(0);
expect(polyLabels[12]).toBe(0);
expect(polyLabels[13]).toBe(0);
expect(polyLabels[14]).toBe(WALL_SUB_LABEL_NONE);
expect(areas[0]).toBe(14);
});