feat: add manual lasso wall splitting with magnetic edge-snapping and active contour refinement
- New magneticLasso module: Sobel energy map + Dijkstra shortest-path + Douglas-Peucker - New activeContour module: greedy snake + balloon force for polygon-to-edge refinement - wallTextureSplit: edge barrier mask, morphological mask hole closing, Moore boundary tracing, simplified polygon contours, manual pick map patching, gap absorption - MaskSegmentCanvas: full lasso gesture pipeline (tap vertices, drag, magnetic paths, close polygon, endLasso/cancelLasso/deleteLasso) - New maskConfig options: splitWallsEdgeBarrierThreshold, splitWallsCloseMaskRadius, manualSplitWalls, manualSplitWallsMaxCount, manualSplitWallsGapAbsorbDilatePx, magneticLasso, activeContourRefine - New ref methods: startLasso, endLasso, cancelLasso, getManualRegions, deleteLasso - New exported types: LassoPolygon, ManualWallPartition - RegionMaskData carries indexToName and wallSemanticIdx through downsample - Simplify README to point to documentation site - Update documentation site (EN + ZH-CN) with all new APIs and interaction guide - Example app: lasso mode toggles and operation buttons Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
26d3717169
commit
3a3f07628d
@ -8,7 +8,14 @@ import {
|
||||
setMaskSegmentRuntimeConfig,
|
||||
} from '../src/utils/maskSegmentRuntime';
|
||||
import type { SegmentMaskResult } from '../src/utils/maskSegmentation';
|
||||
import { splitWallRegionsByTexture } from '../src/utils/wallTextureSplit';
|
||||
import {
|
||||
buildPickMapAfterWallSplit,
|
||||
dilatePickBuffer1px,
|
||||
patchPickMapForManualWallSplit,
|
||||
splitWallRegionsByTexture,
|
||||
absorbSmallWallGapsForLassoPolygons,
|
||||
WALL_SUB_LABEL_NONE,
|
||||
} from '../src/utils/wallTextureSplit';
|
||||
|
||||
const WALL_IDX = 3;
|
||||
const IGNORE = 255;
|
||||
@ -180,6 +187,196 @@ test('same-color wall with lighting gradient stays one region', () => {
|
||||
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;
|
||||
@ -196,3 +393,82 @@ test('single-texture wall becomes wall-1 when splitWalls enabled', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
2
dist/components/MaskSegmentCanvas.js
vendored
2
dist/components/MaskSegmentCanvas.js
vendored
File diff suppressed because one or more lines are too long
67
dist/components/MaskSegmentCanvas.types.d.ts
vendored
67
dist/components/MaskSegmentCanvas.types.d.ts
vendored
@ -53,6 +53,33 @@ export type MaskSegmentConfig = {
|
||||
splitWallsChromaBlurRadius?: number;
|
||||
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
||||
splitWallsNeutralChromaMax?: number;
|
||||
/**
|
||||
* Wall split: raw per-channel BGR Sobel gradient threshold for edge barriers
|
||||
* (0 = disabled). Uses un-normalized 8‑bit Sobel magnitude on each B/G/R
|
||||
* channel (max per pixel, range ≈ 0–1442). Visible wall seams ≈ 120–280,
|
||||
* subtle lighting gradients ≈ 20–80. Default: 160.
|
||||
*/
|
||||
splitWallsEdgeBarrierThreshold?: number;
|
||||
/**
|
||||
* Morphological close radius for wall mask holes before component labeling.
|
||||
* Non-wall pixels inside the wall boundary (windows, doors, mask artefacts)
|
||||
* are temporarily filled so the BFS can bridge across them. Default: 3.
|
||||
* Set to 0 to disable.
|
||||
*/
|
||||
splitWallsCloseMaskRadius?: number;
|
||||
/** When true, disables automatic texture-based wall splitting (splitWalls). Manual lasso partitioning is used instead. */
|
||||
manualSplitWalls?: boolean;
|
||||
/** wall mask only, max number of manual wall sub-regions defined by lasso */
|
||||
manualSplitWallsMaxCount?: number;
|
||||
/**
|
||||
* Manual lasso: morphological dilation radius (seg pixels) used to merge thin
|
||||
* unassigned wall pockets adjacent to the drawn polygon.
|
||||
*/
|
||||
manualSplitWallsGapAbsorbDilatePx?: number;
|
||||
/** When true, lasso mode uses edge-snapping (Sobel gradient + Dijkstra shortest-path). Default: false. */
|
||||
magneticLasso?: boolean;
|
||||
/** After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges. Default: false. */
|
||||
activeContourRefine?: boolean;
|
||||
};
|
||||
export type PaintConfig = {
|
||||
palette?: BgrColor[];
|
||||
@ -117,6 +144,32 @@ export type PaintBrushRequiredPayload = {
|
||||
regionName: string;
|
||||
};
|
||||
export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload;
|
||||
/** A lasso polygon defined by user tapping vertices on the wall mask area. Vertices are in normalized image coordinates (0..1). */
|
||||
export type LassoPolygon = {
|
||||
id: string;
|
||||
vertices: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
isClosed: boolean;
|
||||
};
|
||||
/** Result of converting a lasso polygon into an actual wall sub-region. */
|
||||
export type ManualWallPartition = {
|
||||
id: string;
|
||||
regionId: number;
|
||||
regionName: string;
|
||||
vertices: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
bbox: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
area: number;
|
||||
};
|
||||
export type MaskSegmentCanvasRef = {
|
||||
reset: () => void;
|
||||
swap: (showOrigin?: boolean) => void;
|
||||
@ -133,6 +186,20 @@ export type MaskSegmentCanvasRef = {
|
||||
getPaintedRegions: () => PaintedRegionRecord[];
|
||||
/** Returns the most recent auto-export or save() result, if any. */
|
||||
getLastExport?: () => SavePaintResult | null;
|
||||
/** Enter lasso mode — user can tap wall mask area to place polygon vertices. */
|
||||
startLasso: () => void;
|
||||
/** Exit lasso mode, convert all closed lasso polygons into wall-X sub-regions for painting. Returns the partition results. */
|
||||
endLasso: () => ManualWallPartition[];
|
||||
/**
|
||||
* Exit the current lasso editing session without saving regions.
|
||||
* Discards in-progress vertices and closed polygons from this session only;
|
||||
* previously committed manual wall partitions are kept.
|
||||
*/
|
||||
cancelLasso: () => void;
|
||||
/** Get the current manual wall partitions (only valid after endLasso has been called). */
|
||||
getManualRegions: () => ManualWallPartition[];
|
||||
/** Delete a lasso polygon by its id. Committed partitions also drop paint on that region. */
|
||||
deleteLasso: (id: string) => void;
|
||||
};
|
||||
export type MaskSegmentCanvasProps = {
|
||||
originUrl?: string;
|
||||
|
||||
2
dist/components/MaskSegmentCanvas.types.js
vendored
2
dist/components/MaskSegmentCanvas.types.js
vendored
@ -1 +1 @@
|
||||
"use strict";var r=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of s(e))!l.call(n,i)&&i!==t&&r(n,i,{get:()=>e[i],enumerable:!(o=a(e,i))||o.enumerable});return n};var m=n=>g(r({},"__esModule",{value:!0}),n);var u={};module.exports=m(u);
|
||||
"use strict";var r=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var m=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of s(e))!l.call(n,i)&&i!==t&&r(n,i,{get:()=>e[i],enumerable:!(o=a(e,i))||o.enumerable});return n};var g=n=>m(r({},"__esModule",{value:!0}),n);var u={};module.exports=g(u);
|
||||
|
||||
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
export { default } from './components/MaskSegmentCanvas';
|
||||
export type { BgrColor, InteractionConfig, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types';
|
||||
export type { BgrColor, InteractionConfig, LassoPolygon, ManualWallPartition, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types';
|
||||
export { BASEBOARD_SEMANTIC_NAME, MASK_SEMANTIC_COLORS, } from './utils/maskSemanticPalette';
|
||||
export { createRuntimeConfig, DEFAULT_INTERACTION_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_PIPELINE_CONFIG, PIPELINE_HIGH, PIPELINE_LOW, PIPELINE_MEDIUM, PIPELINE_PRESETS, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from './utils/maskSegmentRuntime';
|
||||
export { prewarmPngBgrCache, prewarmPngBgrCacheAsync, } from './utils/pngImage';
|
||||
|
||||
2
dist/index.js
vendored
2
dist/index.js
vendored
@ -1 +1 @@
|
||||
"use strict";var m=Object.create;var i=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var M=Object.getPrototypeOf,f=Object.prototype.hasOwnProperty;var p=(a,n)=>{for(var t in n)i(a,t,{get:n[t],enumerable:!0})},g=(a,n,t,P)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of I(n))!f.call(a,o)&&o!==t&&i(a,o,{get:()=>n[o],enumerable:!(P=E(n,o))||P.enumerable});return a};var _=(a,n,t)=>(t=a!=null?m(M(a)):{},g(n||!a||!a.__esModule?i(t,"default",{value:a,enumerable:!0}):t,a)),l=a=>g(i({},"__esModule",{value:!0}),a);var A={};p(A,{BASEBOARD_SEMANTIC_NAME:()=>r.BASEBOARD_SEMANTIC_NAME,DEFAULT_INTERACTION_CONFIG:()=>e.DEFAULT_INTERACTION_CONFIG,DEFAULT_MASK_CONFIG:()=>e.DEFAULT_MASK_CONFIG,DEFAULT_PAINT_CONFIG:()=>e.DEFAULT_PAINT_CONFIG,DEFAULT_PIPELINE_CONFIG:()=>e.DEFAULT_PIPELINE_CONFIG,MASK_SEMANTIC_COLORS:()=>r.MASK_SEMANTIC_COLORS,PIPELINE_HIGH:()=>e.PIPELINE_HIGH,PIPELINE_LOW:()=>e.PIPELINE_LOW,PIPELINE_MEDIUM:()=>e.PIPELINE_MEDIUM,PIPELINE_PRESETS:()=>e.PIPELINE_PRESETS,createRuntimeConfig:()=>e.createRuntimeConfig,default:()=>C.default,getMaskSegmentRuntimeConfig:()=>e.getMaskSegmentRuntimeConfig,prewarmPngBgrCache:()=>s.prewarmPngBgrCache,prewarmPngBgrCacheAsync:()=>s.prewarmPngBgrCacheAsync,resolveAssetPath:()=>S.resolveAssetPath,resolvePipelineConfig:()=>e.resolvePipelineConfig,setMaskSegmentRuntimeConfig:()=>e.setMaskSegmentRuntimeConfig});module.exports=l(A);var C=_(require("./components/MaskSegmentCanvas")),r=require("./utils/maskSemanticPalette"),e=require("./utils/maskSegmentRuntime"),s=require("./utils/pngImage"),S=require("./utils/resolveAssetPath");
|
||||
"use strict";var m=Object.create;var i=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,M=Object.prototype.hasOwnProperty;var f=(a,n)=>{for(var t in n)i(a,t,{get:n[t],enumerable:!0})},g=(a,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of I(n))!M.call(a,o)&&o!==t&&i(a,o,{get:()=>n[o],enumerable:!(r=E(n,o))||r.enumerable});return a};var p=(a,n,t)=>(t=a!=null?m(l(a)):{},g(n||!a||!a.__esModule?i(t,"default",{value:a,enumerable:!0}):t,a)),_=a=>g(i({},"__esModule",{value:!0}),a);var A={};f(A,{BASEBOARD_SEMANTIC_NAME:()=>s.BASEBOARD_SEMANTIC_NAME,DEFAULT_INTERACTION_CONFIG:()=>e.DEFAULT_INTERACTION_CONFIG,DEFAULT_MASK_CONFIG:()=>e.DEFAULT_MASK_CONFIG,DEFAULT_PAINT_CONFIG:()=>e.DEFAULT_PAINT_CONFIG,DEFAULT_PIPELINE_CONFIG:()=>e.DEFAULT_PIPELINE_CONFIG,MASK_SEMANTIC_COLORS:()=>s.MASK_SEMANTIC_COLORS,PIPELINE_HIGH:()=>e.PIPELINE_HIGH,PIPELINE_LOW:()=>e.PIPELINE_LOW,PIPELINE_MEDIUM:()=>e.PIPELINE_MEDIUM,PIPELINE_PRESETS:()=>e.PIPELINE_PRESETS,createRuntimeConfig:()=>e.createRuntimeConfig,default:()=>C.default,getMaskSegmentRuntimeConfig:()=>e.getMaskSegmentRuntimeConfig,prewarmPngBgrCache:()=>P.prewarmPngBgrCache,prewarmPngBgrCacheAsync:()=>P.prewarmPngBgrCacheAsync,resolveAssetPath:()=>S.resolveAssetPath,resolvePipelineConfig:()=>e.resolvePipelineConfig,setMaskSegmentRuntimeConfig:()=>e.setMaskSegmentRuntimeConfig});module.exports=_(A);var C=p(require("./components/MaskSegmentCanvas")),s=require("./utils/maskSemanticPalette"),e=require("./utils/maskSegmentRuntime"),P=require("./utils/pngImage"),S=require("./utils/resolveAssetPath");
|
||||
|
||||
46
dist/utils/activeContour.d.ts
vendored
Normal file
46
dist/utils/activeContour.d.ts
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Active Contour Model — greedy snake + balloon force.
|
||||
*
|
||||
* After the user finishes a lasso polygon, this module refines the boundary
|
||||
* vertices outward toward the true wall-mask edge. Each vertex samples
|
||||
* positions along its outward normal and picks the one with lowest energy.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Subdivide polygon to get evenly-spaced control points
|
||||
* 2. For each iteration (3-5 rounds):
|
||||
* a. Compute outward normal at each point
|
||||
* b. Sample N positions along the normal (outward first, then inward)
|
||||
* c. Score each position: E = E_edge + E_smooth
|
||||
* d. Move vertex to min-energy position (constrained to wall mask)
|
||||
* 3. Douglas-Peucker simplify
|
||||
*/
|
||||
import { type WallMaskSample } from './magneticLasso';
|
||||
export type ActiveContourOpts = {
|
||||
/** Number of greedy iterations (default 3). */
|
||||
iterations?: number;
|
||||
/** Number of sample positions along normal per direction (default 6). */
|
||||
samplesPerDirection?: number;
|
||||
/** Step size (norm coords) between samples (default 0.003). */
|
||||
sampleStep?: number;
|
||||
/** Smoothness weight — higher keeps vertices more uniformly spaced (default 0.15). */
|
||||
smoothWeight?: number;
|
||||
/** Edge weight — higher makes contour hug mask boundary (default 1.0). */
|
||||
edgeWeight?: number;
|
||||
/** Balloon bias — extra outward push per iteration (default 0.002). */
|
||||
balloonForce?: number;
|
||||
/** Minimum vertex count for a polygon to be refined (default 4). */
|
||||
minVertices?: number;
|
||||
};
|
||||
/**
|
||||
* Refine a single closed lasso polygon to hug the wall-mask outer boundary.
|
||||
*
|
||||
* Returns a new vertex list (not mutated in place). Returns the original
|
||||
* polygon unchanged if it has too few vertices or no wall mask is given.
|
||||
*/
|
||||
export declare function refinePolygonToWallEdges(vertices: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[], mask: WallMaskSample, opts?: ActiveContourOpts): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
1
dist/utils/activeContour.js
vendored
Normal file
1
dist/utils/activeContour.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
"use strict";var W=Object.defineProperty;var q=Object.getOwnPropertyDescriptor;var E=Object.getOwnPropertyNames;var V=Object.prototype.hasOwnProperty;var B=(n,e)=>{for(var m in e)W(n,m,{get:e[m],enumerable:!0})},N=(n,e,m,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of E(e))!V.call(n,t)&&t!==m&&W(n,t,{get:()=>e[t],enumerable:!(o=q(e,t))||o.enumerable});return n};var U=n=>N(W({},"__esModule",{value:!0}),n);var J={};B(J,{refinePolygonToWallEdges:()=>G});module.exports=U(J);var T=require("./magneticLasso");const L={iterations:3,samplesPerDirection:6,sampleStep:.003,smoothWeight:.15,edgeWeight:1,balloonForce:.002,minVertices:4};function _(n,e,m){const o=e.x-n.x,t=e.y-n.y,r=m.x-e.x,s=m.y-e.y,c=o+r,u=t+s,f=-u,h=c,l=u,x=-c,a=r*h-s*f,g=r*x-s*l,i=Math.hypot(f,h);if(i<1e-8)return{x:0,y:0};const[y,b]=a<g?[f,h]:[l,x];return{x:y/i,y:b/i}}function j(n,e,m,o){const{labels:t,baseboardBinary:r,cols:s,rows:c,wallSemanticIdx:u}=m;if(s<=0||c<=0)return o;const f=Math.round(n*s),h=Math.round(e*c),l=Math.ceil(o);let x=o+1;for(let a=-l;a<=l;a++)for(let g=-l;g<=l;g++){const i=f+g,y=h+a;if(i<0||y<0||i>=s||y>=c)continue;const b=y*s+i;if(!(t[b]!==u||r[b])&&(i===0||y===0||i===s-1||y===c-1||t[b-1]!==u||t[b+1]!==u||t[b-s]!==u||t[b+s]!==u)){const d=Math.hypot(g,a);d<x&&(x=d)}}return x/Math.max(s,c)}function O(n,e,m,o,t,r){if(!(0,T.isNormPointOnWallMask)(n,e,m))return 1e6;const s=j(n,e,m,o)/o;let c=0;if(t.length>=2){const u=t[0],f=t[t.length-1],h=(u.x+f.x)/2,l=(u.y+f.y)/2;c=Math.hypot(n-h,e-l)}return r.edgeWeight*s+r.smoothWeight*c}function z(n,e){if(n.length<2)return[...n];const m=[],o=n.length;for(let t=0;t<o;t++){const r=n[t],s=n[(t+1)%o];m.push({...r});const c=Math.hypot(s.x-r.x,s.y-r.y),u=Math.floor(c/e);if(u>1)for(let f=1;f<u;f++){const h=f/u;m.push({x:r.x+h*(s.x-r.x),y:r.y+h*(s.y-r.y)})}}return m}function G(n,e,m){const o={...L,...m};if(n.length<o.minVertices||!e)return[...n];let t=z(n,o.sampleStep*2);const r=o.samplesPerDirection*o.sampleStep*Math.max(e.cols,e.rows),s=o.samplesPerDirection*o.sampleStep*Math.max(e.cols,e.rows);for(let h=0;h<o.iterations;h++){const l=[],x=t.length;for(let a=0;a<x;a++){const g=t[(a-1+x)%x],i=t[a],y=t[(a+1)%x],b=_(g,i,y);if(b.x===0&&b.y===0){l.push({...i});continue}let d={...i},p=O(i.x,i.y,e,s,[g,y],o);const M=o.balloonForce*(h+1);for(let S=1;S<=o.samplesPerDirection;S++){const P=S*o.sampleStep,D=i.x+b.x*(P+M),w=i.y+b.y*(P+M),A=O(D,w,e,s,[l.length>0?l[l.length-1]:t[(a-1+x)%x],y],o);A<p&&(p=A,d={x:D,y:w});const I=i.x-b.x*P,C=i.y-b.y*P,F=O(I,C,e,s,[l.length>0?l[l.length-1]:t[(a-1+x)%x],y],o);F<p&&(p=F,d={x:I,y:C})}l.push(d)}t=l}const c=H(t,.002);if(c.length<3)return[...n];const u=c[0],f=c[c.length-1];return Math.hypot(u.x-f.x,u.y-f.y)>5e-4&&c.push({...u}),c}function H(n,e){if(n.length<=2)return[...n];const m=new Uint8Array(n.length);m[0]=1,m[n.length-1]=1;function o(r,s){if(s-r<=1)return;const c=n[r].x,u=n[r].y,f=n[s].x,h=n[s].y,l=f-c,x=h-u,a=l*l+x*x;let g=0,i=r;for(let y=r+1;y<s;y++){let b;if(a===0)b=Math.hypot(n[y].x-c,n[y].y-u);else{const d=Math.max(0,Math.min(1,((n[y].x-c)*l+(n[y].y-u)*x)/a)),p=c+d*l,M=u+d*x;b=Math.hypot(n[y].x-p,n[y].y-M)}b>g&&(g=b,i=y)}g>e&&(m[i]=1,o(r,i),o(i,s))}o(0,n.length-1);const t=[];for(let r=0;r<n.length;r++)if(m[r]){if(t.length>0){const s=t[t.length-1];if(Math.hypot(n[r].x-s.x,n[r].y-s.y)<.001)continue}t.push({x:n[r].x,y:n[r].y})}return t}
|
||||
112
dist/utils/magneticLasso.d.ts
vendored
Normal file
112
dist/utils/magneticLasso.d.ts
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Magnetic Lasso — edge-snapping polygon placement for manual wall splitting.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. buildEnergyMap → grayscale + downsample + Sobel gradient → energy grid
|
||||
* 2. findShortestPath → Dijkstra 8-connected on low-energy (edge) pixels
|
||||
* 3. extractCornerPoints → Douglas-Peucker simplification on raw path
|
||||
* 4. upscalePath → map energy-space coords back to original image coords
|
||||
*/
|
||||
export type EnergyMap = {
|
||||
/** Float32Array per-pixel energy values [0…1]; low = edge, high = flat */
|
||||
map: Float32Array;
|
||||
w: number;
|
||||
h: number;
|
||||
/** Downscale ratio: energyDim / sourceDim (≈ em.w / sourceCols) */
|
||||
scale: number;
|
||||
/** Optional 0/1 mask at energy resolution; 0 = blocked for pathfinding */
|
||||
traversable?: Uint8Array;
|
||||
};
|
||||
/** Seg-resolution wall mask used to constrain lasso vertices. */
|
||||
export type WallMaskSample = {
|
||||
labels: Uint8Array;
|
||||
baseboardBinary: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
wallSemanticIdx: number;
|
||||
};
|
||||
/** True when norm coords fall on a wall semantic pixel (excludes baseboard). */
|
||||
export declare function isNormPointOnWallMask(normX: number, normY: number, mask: WallMaskSample): boolean;
|
||||
export declare function filterVerticesToWallMask<T extends {
|
||||
x: number;
|
||||
y: number;
|
||||
}>(vertices: T[], mask: WallMaskSample): T[];
|
||||
/**
|
||||
* Snap a normalized point to the nearest wall-mask boundary pixel when the
|
||||
* touch falls within `snapRadiusSegPx` (segmentation resolution) of the edge.
|
||||
*/
|
||||
export declare function snapNormPointToWallEdge(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/**
|
||||
* Prefer wall-mask corner pixels (L-shaped outer boundary), then plain edge.
|
||||
* Used when the user taps without dragging.
|
||||
*/
|
||||
export declare function snapNormPointToWallCornerOrEdge(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/**
|
||||
* During vertex drag: snap to corner/edge when near, otherwise keep interior
|
||||
* wall points so the anchor can move freely on the wall mask.
|
||||
*/
|
||||
export declare function resolveLassoWallDragPoint(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
export declare function buildWallAllowedMask(labels: Uint8Array, baseboardBinary: Uint8Array, wallSemanticIdx: number): Uint8Array | null;
|
||||
/**
|
||||
* Build per-pixel energy map from BGR buffer.
|
||||
* 1. Convert to grayscale via luminance weights
|
||||
* 2. Downsample so longest side ≤ targetMaxSide
|
||||
* 3. Apply Sobel 3×3 → gradient magnitude G
|
||||
* 4. Energy = 1 / (1 + G), clamped to [0, 1]
|
||||
*/
|
||||
export declare function buildEnergyMap(bgrBuffer: Uint8Array, cols: number, rows: number, targetMaxSide?: number, allowedMask?: Uint8Array | null): EnergyMap;
|
||||
/**
|
||||
* Dijkstra shortest-path on 8-connected grid.
|
||||
* Cost at each pixel = energy[pixel] * COST_SCALE (integer).
|
||||
* Diagonal steps cost √2 × the neighbour's energy.
|
||||
*
|
||||
* Returns ordered path [start, …, end] in energy-map pixel space.
|
||||
*/
|
||||
export declare function findShortestPath(energy: Float32Array, energyW: number, energyH: number, sx: number, sy: number, ex: number, ey: number, traversable?: Uint8Array | null): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
/**
|
||||
* Douglas-Peucker simplification. Keeps points where the perpendicular
|
||||
* distance from the line segment exceeds epsilon.
|
||||
*
|
||||
* After DP, also enforces a minimum distance between consecutive anchors
|
||||
* to avoid overly dense clusters.
|
||||
*/
|
||||
export declare function extractCornerPoints(path: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[], minDistance?: number, epsilon?: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
/** Map normalized image coords (0..1) to energy-map pixel coords. */
|
||||
export declare function normToEnergyPoint(normX: number, normY: number, em: EnergyMap): {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/** Map energy-map pixel coords back to normalized image coords. */
|
||||
export declare function energyPointsToNorm(points: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[], em: EnergyMap): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
/** Map energy-map pixel coords back to original image coords. */
|
||||
export declare function upscalePath(points: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[], scale: number, originW: number, originH: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
1
dist/utils/magneticLasso.js
vendored
Normal file
1
dist/utils/magneticLasso.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/utils/maskOutlinePaths.d.ts
vendored
1
dist/utils/maskOutlinePaths.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
import { type SkPath } from '@shopify/react-native-skia';
|
||||
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
|
||||
export declare function floodFillComponent(binary: Uint8Array, cols: number, rows: number, seedX: number, seedY: number): Uint8Array | null;
|
||||
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
2
dist/utils/maskOutlinePaths.js
vendored
2
dist/utils/maskOutlinePaths.js
vendored
File diff suppressed because one or more lines are too long
2
dist/utils/maskSegmentRuntime.js
vendored
2
dist/utils/maskSegmentRuntime.js
vendored
File diff suppressed because one or more lines are too long
4
dist/utils/maskSegmentation.d.ts
vendored
4
dist/utils/maskSegmentation.d.ts
vendored
@ -85,6 +85,10 @@ export type RegionMaskData = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
wallSubLabels?: Uint8Array;
|
||||
/** Semantic index → name table captured at segmentation time (must match labels buffer). */
|
||||
indexToName?: string[];
|
||||
/** Wall semantic index in labels buffer (captured at segmentation time). */
|
||||
wallSemanticIdx?: number;
|
||||
};
|
||||
/** downsample mask path building (screen display does not need segmentation resolution, click still uses full resolution pickMap) */
|
||||
export declare function downsampleMaskDataForPaths(maskData: RegionMaskData, maxLongSide: number): RegionMaskData;
|
||||
|
||||
2
dist/utils/maskSegmentation.js
vendored
2
dist/utils/maskSegmentation.js
vendored
File diff suppressed because one or more lines are too long
18
dist/utils/wallTextureSplit.d.ts
vendored
18
dist/utils/wallTextureSplit.d.ts
vendored
@ -1,6 +1,24 @@
|
||||
import type { SegmentMaskResult } from './maskSegmentation';
|
||||
/** Placeholder value for non-wall pixels in wallSubLabels */
|
||||
export declare const WALL_SUB_LABEL_NONE = 255;
|
||||
export declare function buildPickMapAfterWallSplit(labels: Uint8Array, baseboardBinary: Uint8Array, wallIdx: number, wallSubLabels: Uint8Array, indexToName: string[], nameToId: Map<string, number>, cols: number, rows: number): Uint8Array;
|
||||
/**
|
||||
* Manual lasso split: copy the existing pick map and rewrite wall pixels only.
|
||||
* Non-wall pick codes stay identical so prior paints and hit-testing remain stable.
|
||||
*/
|
||||
export declare function patchPickMapForManualWallSplit(existingPick: Uint8Array, labels: Uint8Array, baseboardBinary: Uint8Array, wallIdx: number, wallSubLabels: Uint8Array, nameToId: Map<string, number>, cols: number, rows: number): Uint8Array;
|
||||
export declare function dilatePickBuffer1px(pick: Uint8Array, cols: number, rows: number): Uint8Array;
|
||||
export type LassoPolyBBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
/**
|
||||
* Morphologically dilate each lasso polygon into adjacent unassigned wall pixels
|
||||
* (up to `dilateRadius` seg pixels) so thin gaps against the wall mask merge in.
|
||||
*/
|
||||
export declare function absorbSmallWallGapsForLassoPolygons(polyLabels: Uint8Array, polyCount: number, areas: number[], bboxes: LassoPolyBBox[], labels: Uint8Array, baseboardBinary: Uint8Array, wallSemanticIdx: number, priorAssignedLabels: Uint8Array, cols: number, rows: number, dilateRadius: number): void;
|
||||
/**
|
||||
* After semantic segmentation, subdivide the wall region into wall-1, wall-2… by source image texture features
|
||||
*/
|
||||
|
||||
2
dist/utils/wallTextureSplit.js
vendored
2
dist/utils/wallTextureSplit.js
vendored
File diff suppressed because one or more lines are too long
@ -23,6 +23,8 @@ import MaskSegmentCanvas, {
|
||||
type PaintConfig,
|
||||
type InteractionConfig,
|
||||
type SavePaintResult,
|
||||
type LassoPolygon,
|
||||
type ManualWallPartition,
|
||||
MASK_SEMANTIC_COLORS,
|
||||
BASEBOARD_SEMANTIC_NAME,
|
||||
prewarmPngBgrCacheAsync,
|
||||
@ -38,6 +40,7 @@ import MaskSegmentCanvas, {
|
||||
| Component | `MaskSegmentCanvas` (default) |
|
||||
| Ref / Props types | `MaskSegmentCanvasRef`, `MaskSegmentCanvasProps` |
|
||||
| Session / callback types | `MaskSegmentSession`, `PaintCallbackPayload`, `PaintedRegionRecord`, `SavePaintResult` |
|
||||
| Lasso types | `LassoPolygon`, `ManualWallPartition` |
|
||||
| Watch types | `MaskSegmentWatchState`, `MaskSegmentWatchDetail` |
|
||||
| Config types | `PipelineConfig`, `MaskSegmentConfig`, `PaintConfig`, `InteractionConfig` |
|
||||
| Semantic colors | `MASK_SEMANTIC_COLORS`, `BASEBOARD_SEMANTIC_NAME` |
|
||||
|
||||
@ -29,5 +29,23 @@ title: "Props: maskConfig"
|
||||
| `splitWallsColorDistSq` | `number` | `1400` | Connected-component chroma mean distance squared threshold |
|
||||
| `splitWallsChromaBlurRadius` | `number` | `5` | Reserved: chroma smoothing radius |
|
||||
| `splitWallsNeutralChromaMax` | `number` | `14` | White/gray wall low-chroma radius; forced boundary from colored walls |
|
||||
| `splitWallsEdgeBarrierThreshold` | `number` | `160` | Raw per-channel BGR Sobel gradient threshold for edge barriers (0 = disabled). Visible wall seams ≈ 120–280, subtle lighting gradients ≈ 20–80 |
|
||||
| `splitWallsCloseMaskRadius` | `number` | `3` | Morphological close radius for wall mask holes (windows, doors) before component labeling. 0 = disable |
|
||||
| `manualSplitWalls` | `boolean` | `false` | When `true`, disables automatic texture-based wall splitting. Manual lasso partitioning is used instead |
|
||||
| `manualSplitWallsMaxCount` | `number` | `8` | Maximum number of manual wall sub-regions defined by lasso |
|
||||
| `manualSplitWallsGapAbsorbDilatePx` | `number` | `5` | Morphological dilation radius (seg pixels) to merge thin unassigned wall pockets adjacent to the drawn polygon |
|
||||
| `magneticLasso` | `boolean` | `false` | When `true`, lasso mode uses edge-snapping via Sobel gradient + Dijkstra shortest-path |
|
||||
| `activeContourRefine` | `boolean` | `false` | After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges |
|
||||
|
||||
When `splitWalls` is enabled, the single `wall` region is replaced by multiple `wall-N` sub-regions, each independently paintable and undoable. Old sessions with `regionName: 'wall'` cannot map to new sub-region names and must be repainted.
|
||||
|
||||
### Manual Wall Split (Lasso Mode)
|
||||
|
||||
When `manualSplitWalls` is enabled, automatic texture-based wall splitting is disabled. Instead, users must use the **Lasso** feature to manually draw polygons on the wall area:
|
||||
|
||||
- Call `ref.startLasso()` to enter lasso mode, then tap on wall areas to place polygon vertices.
|
||||
- Enable `magneticLasso` for edge-snapping — paths will follow strong image edges (Sobel gradient + Dijkstra shortest-path).
|
||||
- Enable `activeContourRefine` to automatically expand vertices outward toward the wall-mask boundary after lasso completion.
|
||||
- Call `ref.endLasso()` to convert closed lasso polygons into `wall-N` sub-regions for painting.
|
||||
- Use `splitWallsCloseMaskRadius` to fill wall mask holes (windows, doors) before component labeling during automatic split.
|
||||
- Use `splitWallsEdgeBarrierThreshold` to prevent BFS from crossing strong edges (window frames, door frames) during automatic split.
|
||||
|
||||
@ -20,16 +20,29 @@ Accessed via `ref` (type `MaskSegmentCanvasRef`):
|
||||
| `resegment` | `() => Promise<void>` | Clear PNG cache and re-segment |
|
||||
| `getRegions` | `() => SegmentRegion[]` | Snapshot of current region list |
|
||||
| `getPaintedRegions` | `() => PaintedRegionRecord[]` | Snapshot of current paint records |
|
||||
| `getLastExport` | `() => SavePaintResult \| null` | Returns the most recent auto-export or `save()` result, if any |
|
||||
| `startLasso` | `() => void` | Enter lasso mode — user can tap wall mask area to place polygon vertices |
|
||||
| `endLasso` | `() => ManualWallPartition[]` | Exit lasso mode, convert all closed lasso polygons into `wall-X` sub-regions for painting |
|
||||
| `cancelLasso` | `() => void` | Exit the current lasso editing session without saving regions |
|
||||
| `getManualRegions` | `() => ManualWallPartition[]` | Get the current manual wall partitions (only valid after `endLasso`) |
|
||||
| `deleteLasso` | `(id: string) => void` | Delete a lasso polygon by its id. Committed partitions also drop paint on that region |
|
||||
|
||||
## SavePaintResult
|
||||
|
||||
`{ filePath, width, height, paintedCount, previewPath? }`
|
||||
|
||||
## ManualWallPartition
|
||||
|
||||
`{ id, regionId, regionName, vertices, bbox, area }`
|
||||
|
||||
Returned by `endLasso()` and `getManualRegions()`. Each partition maps a lasso polygon to a `wall-N` sub-region.
|
||||
|
||||
## Code Examples
|
||||
|
||||
```tsx
|
||||
const ref = useRef<MaskSegmentCanvasRef>(null);
|
||||
|
||||
// Paint operations
|
||||
ref.current?.reset();
|
||||
ref.current?.swap(); // toggle
|
||||
ref.current?.swap(true); // force show origin
|
||||
@ -47,6 +60,15 @@ await ref.current?.resegment();
|
||||
|
||||
const regions = ref.current?.getRegions();
|
||||
const painted = ref.current?.getPaintedRegions();
|
||||
|
||||
// Lasso operations
|
||||
ref.current?.startLasso(); // enter lasso mode
|
||||
// ... user taps wall areas to place vertices ...
|
||||
const partitions = ref.current?.endLasso(); // convert polygons to wall-N regions
|
||||
ref.current?.cancelLasso(); // discard in-progress lasso
|
||||
|
||||
const manualRegions = ref.current?.getManualRegions();
|
||||
ref.current?.deleteLasso('lasso_1'); // remove a specific polygon
|
||||
```
|
||||
|
||||
> `save` depends on the working buffer and pickMap being ready (typically after `interactive`); throws `'Image not ready, cannot save'` if not ready.
|
||||
|
||||
@ -5,9 +5,33 @@ title: Interaction Guide
|
||||
|
||||
# 🎮 Interaction Guide
|
||||
|
||||
## Paint Mode
|
||||
|
||||
1. 🔁 **Initial Carousel**: After regions are ready, each region's dashed outline flashes sequentially per `initRegionFlashMs` (default 1s); stops on first user touch.
|
||||
2. 🔍 **Preview (no brush selected)**: Long-press a region to show dashed outline for the connected component under the touch point; tapping a black area shows no outline.
|
||||
3. 🎨 **Paint (brush selected)**: Tap a color in the bottom color bar or call `ref.setPaintColor` (or preselect via `initialPaintColor`), then tap a region to paint; tapping the same region again overwrites the color.
|
||||
4. 💬 **Tap without brush**: No paint is performed; `onPaintCallback` fires with `kind: 'brush_required'`, carrying a `hint` and target region info for the host to show a toast/modal prompting color selection.
|
||||
5. ↩️ **Undo**: Bottom-left button or `ref.reset()`; steps backward through paint history one action at a time.
|
||||
6. 👁️ **Compare with Origin**: Bottom-right button or `ref.swap()`; hides the paint layer to show the original image.
|
||||
|
||||
## Lasso Mode (Manual Wall Split)
|
||||
|
||||
When `manualSplitWalls` is enabled and lasso mode is active:
|
||||
|
||||
7. 🧲 **Enter Lasso**: Call `ref.startLasso()` to activate lasso mode. The lasso polygon overlay (orange) appears.
|
||||
8. 👆 **Place Vertices**: Tap on wall areas to place polygon vertices. Vertices snap to wall-mask edges/corners automatically.
|
||||
9. 🧲 **Magnetic Lasso** (when `magneticLasso: true`): Paths between taps automatically follow strong image edges (green path overlay) via Sobel gradient + Dijkstra shortest-path.
|
||||
10. 🔒 **Close Polygon**: Tap near the first vertex to close the polygon. A closed polygon is outlined in orange.
|
||||
11. ✋ **Drag Vertices**: Touch and drag an existing vertex to reposition it. The vertex snaps to wall boundary/corner points, or stays within the wall mask for interior positions.
|
||||
12. ✅ **End Lasso**: Call `ref.endLasso()` to convert all closed polygons into `wall-N` sub-regions ready for painting.
|
||||
13. 🗑️ **Cancel Lasso**: Call `ref.cancelLasso()` to discard all in-progress lasso polygons without saving.
|
||||
14. 🗑️ **Delete Lasso**: Call `ref.deleteLasso(id)` to remove a previously committed lasso polygon and its associated `wall-N` region.
|
||||
|
||||
### Active Contour Refinement
|
||||
|
||||
When `activeContourRefine: true`, the closed lasso polygon is automatically refined after `endLasso()`:
|
||||
|
||||
- Each vertex samples positions along its outward normal direction
|
||||
- Vertices expand to the nearest wall-mask boundary edge (balloon force)
|
||||
- Douglas-Peucker simplification removes redundant vertices
|
||||
- Result: the polygon hugs the true wall outline rather than the raw tap positions
|
||||
|
||||
@ -14,6 +14,7 @@ A React Native **0.79** interactive mask segmentation library. The core export i
|
||||
- 🧠 **OpenCV** (`react-native-fast-opencv`): mask semantic layout, baseboard patching, region extraction
|
||||
- 🖌️ **Skia RuntimeEffect (SkSL)**: single-pass full-screen shader blending original image + LAB low/high frequency texture color overlays
|
||||
- ✂️ **Skia Path**: dashed outline highlights for regions
|
||||
- 🧲 **Magnetic Lasso**: manual wall partitioning with edge-snapping (Sobel gradient + Dijkstra shortest-path) and Active Contour boundary refinement
|
||||
- 👆 **Interaction**: bottom color bar for brush selection (optional initialization) → tap a region to paint; tapping without a brush selected fires `onPaintCallback` with a hint; long-press without a brush previews the region's dashed outline
|
||||
|
||||
This repository serves as both the **library source** (`src/index.ts`) and a **self-test demo** (root `App.tsx`).
|
||||
@ -30,8 +31,9 @@ This repository serves as both the **library source** (`src/index.ts`) and a **s
|
||||
2. 🧩 **Segment** the mask via OpenCV into semantic regions (walls, ceiling, baseboard, etc.)
|
||||
3. 🎨 **Prepare** LAB frequency-layer textures via SkSL for realistic color blending
|
||||
4. 📐 **Build** Skia dashed-outline paths for each region
|
||||
5. 👆 **Interactive** — users select a brush color and tap regions to paint; paint layers preserve the underlying texture
|
||||
6. 💾 **Save** the composited result as PNG; export a JSON session for draft recovery
|
||||
5. 🧲 **Manual Split** (optional) — draw lasso polygons on walls to subdivide into independently-paintable `wall-N` regions, with optional edge-snapping and Active Contour refinement
|
||||
6. 👆 **Interactive** — users select a brush color and tap regions to paint; paint layers preserve the underlying texture
|
||||
7. 💾 **Save** the composited result as PNG; export a JSON session for draft recovery
|
||||
|
||||
The component emits `onWatch` state transitions through the pipeline so the host app can show appropriate loading states.
|
||||
|
||||
|
||||
@ -17,6 +17,9 @@ MaskSegmentApp/ # Repo root (npm package react-nati
|
||||
│ ├── maskSegmentation.ts
|
||||
│ ├── maskSegmentRuntime.ts
|
||||
│ ├── maskSemanticPalette.ts
|
||||
│ ├── magneticLasso.ts # Edge-snapping lasso (Sobel + Dijkstra)
|
||||
│ ├── activeContour.ts # Active Contour refinement (snake + balloon)
|
||||
│ ├── wallTextureSplit.ts # Automatic & manual wall texture splitting
|
||||
│ └── ...
|
||||
├── example/ # ★ Recommended: consumer-side integration demo
|
||||
│ ├── App.tsx # Full example using only the public API
|
||||
|
||||
@ -23,6 +23,8 @@ import MaskSegmentCanvas, {
|
||||
type PaintConfig,
|
||||
type InteractionConfig,
|
||||
type SavePaintResult,
|
||||
type LassoPolygon,
|
||||
type ManualWallPartition,
|
||||
MASK_SEMANTIC_COLORS,
|
||||
BASEBOARD_SEMANTIC_NAME,
|
||||
prewarmPngBgrCacheAsync,
|
||||
@ -38,6 +40,7 @@ import MaskSegmentCanvas, {
|
||||
| 组件 | `MaskSegmentCanvas`(默认导出) |
|
||||
| Ref / Props 类型 | `MaskSegmentCanvasRef`, `MaskSegmentCanvasProps` |
|
||||
| 会话 / 回调类型 | `MaskSegmentSession`, `PaintCallbackPayload`, `PaintedRegionRecord`, `SavePaintResult` |
|
||||
| 套索类型 | `LassoPolygon`, `ManualWallPartition` |
|
||||
| Watch 类型 | `MaskSegmentWatchState`, `MaskSegmentWatchDetail` |
|
||||
| 配置类型 | `PipelineConfig`, `MaskSegmentConfig`, `PaintConfig`, `InteractionConfig` |
|
||||
| 语义颜色 | `MASK_SEMANTIC_COLORS`, `BASEBOARD_SEMANTIC_NAME` |
|
||||
|
||||
@ -29,5 +29,23 @@ title: "Props:maskConfig"
|
||||
| `splitWallsColorDistSq` | `number` | `1400` | 连通分量色度均值距离平方阈值 |
|
||||
| `splitWallsChromaBlurRadius` | `number` | `5` | 保留:色度平滑半径 |
|
||||
| `splitWallsNeutralChromaMax` | `number` | `14` | 白/灰墙面低色度半径;与彩色墙面的强制边界 |
|
||||
| `splitWallsEdgeBarrierThreshold` | `number` | `160` | 逐通道 BGR Sobel 梯度边缘屏障阈值(0 = 禁用)。可见墙面接缝 ≈ 120–280,细微光照渐变 ≈ 20–80 |
|
||||
| `splitWallsCloseMaskRadius` | `number` | `3` | 组件标注前墙面遮罩孔洞(窗户、门)的形态学闭运算半径。设为 0 禁用 |
|
||||
| `manualSplitWalls` | `boolean` | `false` | 为 `true` 时禁用自动纹理墙面分割,改为手动套索分区 |
|
||||
| `manualSplitWallsMaxCount` | `number` | `8` | 套索定义的最大手动墙面子区域数 |
|
||||
| `manualSplitWallsGapAbsorbDilatePx` | `number` | `5` | 形态学膨胀半径(分割像素),用于合并绘制多边形周围的未分配墙面薄缝 |
|
||||
| `magneticLasso` | `boolean` | `false` | 为 `true` 时,套索模式使用 Sobel 梯度 + Dijkstra 最短路径进行边缘吸附 |
|
||||
| `activeContourRefine` | `boolean` | `false` | 结束套索后,对每个多边形运行主动轮廓精炼,将顶点向外扩展到墙面遮罩边缘 |
|
||||
|
||||
启用 `splitWalls` 后,单个 `wall` 区域将被替换为多个 `wall-N` 子区域,每个子区域可独立上色和撤销。旧会话中 `regionName: 'wall'` 的记录无法映射到新的子区域名称,需重新上色。
|
||||
|
||||
### 手动墙面分割(套索模式)
|
||||
|
||||
当 `manualSplitWalls` 启用时,自动纹理墙面分割被禁用。用户必须使用 **套索(Lasso)** 功能在墙面上手动绘制多边形:
|
||||
|
||||
- 调用 `ref.startLasso()` 进入套索模式,然后在墙面区域点击放置多边形顶点。
|
||||
- 启用 `magneticLasso` 进行边缘吸附 — 路径将沿图像强边缘走(Sobel 梯度 + Dijkstra 最短路径)。
|
||||
- 启用 `activeContourRefine` 在套索完成后自动将顶点向外扩展到墙面遮罩边界。
|
||||
- 调用 `ref.endLasso()` 将闭合套索多边形转换为可上色的 `wall-N` 子区域。
|
||||
- 自动分割时使用 `splitWallsCloseMaskRadius` 填充墙面遮罩孔洞(窗户、门)。
|
||||
- 自动分割时使用 `splitWallsEdgeBarrierThreshold` 阻止 BFS 跨越强边缘(窗框、门框)。
|
||||
|
||||
@ -20,16 +20,29 @@ title: "Ref 方法"
|
||||
| `resegment` | `() => Promise<void>` | 清除 PNG 缓存并重新分割 |
|
||||
| `getRegions` | `() => SegmentRegion[]` | 当前区域列表快照 |
|
||||
| `getPaintedRegions` | `() => PaintedRegionRecord[]` | 当前上色记录快照 |
|
||||
| `getLastExport` | `() => SavePaintResult \| null` | 返回最近一次自动导出或 `save()` 的结果(如有) |
|
||||
| `startLasso` | `() => void` | 进入套索模式 — 用户可在墙面区域点击放置多边形顶点 |
|
||||
| `endLasso` | `() => ManualWallPartition[]` | 退出套索模式,将所有闭合套索多边形转换为可上色的 `wall-X` 子区域 |
|
||||
| `cancelLasso` | `() => void` | 退出当前套索编辑会话,不保存区域 |
|
||||
| `getManualRegions` | `() => ManualWallPartition[]` | 获取当前手动墙面分区(仅在 `endLasso` 调用后有效) |
|
||||
| `deleteLasso` | `(id: string) => void` | 根据 id 删除套索多边形。已提交的分区也会删除该区域上的上色 |
|
||||
|
||||
## SavePaintResult
|
||||
|
||||
`{ filePath, width, height, paintedCount, previewPath? }`
|
||||
|
||||
## ManualWallPartition
|
||||
|
||||
`{ id, regionId, regionName, vertices, bbox, area }`
|
||||
|
||||
由 `endLasso()` 和 `getManualRegions()` 返回。每个分区将一个套索多边形映射到一个 `wall-N` 子区域。
|
||||
|
||||
## 代码示例
|
||||
|
||||
```tsx
|
||||
const ref = useRef<MaskSegmentCanvasRef>(null);
|
||||
|
||||
// 上色操作
|
||||
ref.current?.reset();
|
||||
ref.current?.swap(); // 切换
|
||||
ref.current?.swap(true); // 强制显示原始图像
|
||||
@ -47,6 +60,15 @@ await ref.current?.resegment();
|
||||
|
||||
const regions = ref.current?.getRegions();
|
||||
const painted = ref.current?.getPaintedRegions();
|
||||
|
||||
// 套索操作
|
||||
ref.current?.startLasso(); // 进入套索模式
|
||||
// ... 用户在墙面区域点击放置顶点 ...
|
||||
const partitions = ref.current?.endLasso(); // 将多边形转换为 wall-N 区域
|
||||
ref.current?.cancelLasso(); // 丢弃进行中的套索
|
||||
|
||||
const manualRegions = ref.current?.getManualRegions();
|
||||
ref.current?.deleteLasso('lasso_1'); // 删除指定多边形
|
||||
```
|
||||
|
||||
> `save` 依赖于工作缓冲区和 pickMap 就绪(通常在 `interactive` 之后);如果未就绪则抛出 `'Image not ready, cannot save'`。
|
||||
|
||||
@ -5,9 +5,33 @@ title: 交互指南
|
||||
|
||||
# 🎮 交互指南
|
||||
|
||||
## 上色模式
|
||||
|
||||
1. 🔁 **初始轮播**:区域就绪后,每个区域的虚线轮廓按 `initRegionFlashMs`(默认 1s)依次闪烁;首次用户触摸时停止。
|
||||
2. 🔍 **预览(未选择画笔)**:长按区域可显示触摸点下连通分量的虚线轮廓;点击黑色区域不显示轮廓。
|
||||
3. 🎨 **上色(已选择画笔)**:点击底部颜色条中的颜色或调用 `ref.setPaintColor`(或通过 `initialPaintColor` 预设),然后点击区域上色;再次点击同一区域会覆盖颜色。
|
||||
4. 💬 **无画笔点击**:不执行上色;`onPaintCallback` 触发 `kind: 'brush_required'`,携带提示信息和目标区域信息,供宿主显示 Toast/弹窗提示选择颜色。
|
||||
5. ↩️ **撤销**:左下按钮或 `ref.reset()`;按上色历史逐步后退。
|
||||
6. 👁️ **与原图对比**:右下按钮或 `ref.swap()`;隐藏上色层以显示原图。
|
||||
|
||||
## 套索模式(手动墙面分割)
|
||||
|
||||
当 `manualSplitWalls` 启用且套索模式激活时:
|
||||
|
||||
7. 🧲 **进入套索**:调用 `ref.startLasso()` 激活套索模式。套索多边形叠加层(橙色)出现。
|
||||
8. 👆 **放置顶点**:点击墙面区域放置多边形顶点。顶点会自动吸附到墙面遮罩边缘/角点。
|
||||
9. 🧲 **磁性套索**(当 `magneticLasso: true`):点击之间的路径自动沿图像强边缘走(绿色路径叠加层),通过 Sobel 梯度 + Dijkstra 最短路径实现。
|
||||
10. 🔒 **闭合多边形**:点击第一个顶点附近闭合多边形。闭合多边形以橙色轮廓显示。
|
||||
11. ✋ **拖拽顶点**:触摸并拖拽已有顶点重新定位。顶点吸附到墙面边界/角点,或保持在墙面遮罩内部位置。
|
||||
12. ✅ **结束套索**:调用 `ref.endLasso()` 将所有闭合多边形转换为可上色的 `wall-N` 子区域。
|
||||
13. 🗑️ **取消套索**:调用 `ref.cancelLasso()` 丢弃所有进行中的套索多边形,不保存。
|
||||
14. 🗑️ **删除套索**:调用 `ref.deleteLasso(id)` 删除之前提交的套索多边形及其关联的 `wall-N` 区域。
|
||||
|
||||
### 主动轮廓精炼
|
||||
|
||||
当 `activeContourRefine: true` 时,闭合套索多边形在 `endLasso()` 后自动精炼:
|
||||
|
||||
- 每个顶点沿其外法线方向采样位置
|
||||
- 顶点向外扩展到最近的墙面遮罩边界边缘(气球队列力)
|
||||
- Douglas-Peucker 简化去除冗余顶点
|
||||
- 结果:多边形贴合真实墙面轮廓,而非原始点击位置
|
||||
|
||||
@ -10,6 +10,7 @@ title: 概述
|
||||
- 🧠 **OpenCV** (`react-native-fast-opencv`):遮罩语义布局、踢脚线修补、区域提取
|
||||
- 🖌️ **Skia RuntimeEffect (SkSL)**:单 Pass 全屏着色器,混合原图 + LAB 低频/高频纹理颜色叠加
|
||||
- ✂️ **Skia Path**:区域虚线轮廓高亮
|
||||
- 🧲 **磁性套索(Magnetic Lasso)**:手动墙面分区,支持边缘吸附(Sobel 梯度 + Dijkstra 最短路径)和主动轮廓边界精炼
|
||||
- 👆 **交互**:底部颜色条选择画笔(可选初始化)→ 点击区域上色;未选择画笔时点击会触发 `onPaintCallback` 并附带提示;未选画笔时长按可预览区域的虚线轮廓
|
||||
|
||||
本仓库同时作为 **库源码**(`src/index.ts`)和 **自测 Demo**(根目录 `App.tsx`)。
|
||||
@ -26,8 +27,9 @@ title: 概述
|
||||
2. 🧩 **分割**通过 OpenCV 将遮罩分割为语义区域(墙面、天花板、踢脚线等)
|
||||
3. 🎨 **准备**通过 SkSL 生成 LAB 频域层纹理,实现逼真的颜色混合
|
||||
4. 📐 **构建**每个区域的 Skia 虚线轮廓路径
|
||||
5. 👆 **交互** — 用户选择画笔颜色并点击区域上色;上色层保留底层纹理
|
||||
6. 💾 **保存**合成结果为 PNG;导出 JSON 会话用于草稿恢复
|
||||
5. 🧲 **手动分割**(可选)— 在墙面上绘制套索多边形,将其细分为可独立上色的 `wall-N` 区域,可选边缘吸附和主动轮廓精炼
|
||||
6. 👆 **交互** — 用户选择画笔颜色并点击区域上色;上色层保留底层纹理
|
||||
7. 💾 **保存**合成结果为 PNG;导出 JSON 会话用于草稿恢复
|
||||
|
||||
组件通过 `onWatch` 发出 Pipeline 状态转换,宿主应用可据此显示相应的加载状态。
|
||||
|
||||
|
||||
@ -17,6 +17,9 @@ MaskSegmentApp/ # 仓库根目录(npm 包 react-n
|
||||
│ ├── maskSegmentation.ts
|
||||
│ ├── maskSegmentRuntime.ts
|
||||
│ ├── maskSemanticPalette.ts
|
||||
│ ├── magneticLasso.ts # 边缘吸附套索(Sobel + Dijkstra)
|
||||
│ ├── activeContour.ts # 主动轮廓精炼(Snake + Balloon)
|
||||
│ ├── wallTextureSplit.ts # 自动与手动墙面纹理分割
|
||||
│ └── ...
|
||||
├── example/ # ★ 推荐:消费方集成 Demo
|
||||
│ ├── App.tsx # 仅使用公开 API 的完整示例
|
||||
|
||||
183
example/App.tsx
183
example/App.tsx
@ -24,6 +24,7 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import MaskSegmentCanvas, {
|
||||
type BgrColor,
|
||||
type ManualWallPartition,
|
||||
type MaskSegmentCanvasRef,
|
||||
type MaskSegmentSession,
|
||||
type MaskSegmentWatchState,
|
||||
@ -122,6 +123,11 @@ function App(): React.JSX.Element {
|
||||
// Demo mode
|
||||
const [useCustomColors, setUseCustomColors] = useState(false);
|
||||
const [splitWalls, setSplitWalls] = useState(false);
|
||||
const [manualSplitWalls, setManualSplitWalls] = useState(false);
|
||||
const [magneticLasso, setMagneticLasso] = useState(false);
|
||||
const [activeContourRefine, setActiveContourRefine] = useState(false);
|
||||
const [splitEdgeBarrier, setSplitEdgeBarrier] = useState(false);
|
||||
const [isLassoing, setIsLassoing] = useState(false);
|
||||
const [pipelinePreset, setPipelinePreset] = useState<PipelinePreset>('medium');
|
||||
const [groupIndex, setGroupIndex] = useState(0);
|
||||
|
||||
@ -272,6 +278,77 @@ function App(): React.JSX.Element {
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const handleStartLasso = useCallback(() => {
|
||||
canvasRef.current?.startLasso();
|
||||
setIsLassoing(true);
|
||||
showToast('Lasso mode: tap wall area to place vertices');
|
||||
}, [showToast]);
|
||||
|
||||
const handleEndLasso = useCallback(() => {
|
||||
const parts = canvasRef.current?.endLasso();
|
||||
setIsLassoing(false);
|
||||
if (parts && parts.length > 0) {
|
||||
showToast(`Lasso ended: ${parts.length} wall sub-regions created`);
|
||||
console.log(
|
||||
'[Example] Manual wall partitions:',
|
||||
JSON.stringify(
|
||||
parts.map(p => ({ id: p.id, regionName: p.regionName, area: p.area })),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showToast('Lasso ended (no polygons to convert)');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const handleCancelLasso = useCallback(() => {
|
||||
canvasRef.current?.cancelLasso();
|
||||
setIsLassoing(false);
|
||||
showToast('Lasso cancelled (regions not saved)');
|
||||
}, [showToast]);
|
||||
|
||||
const handleDeleteLasso = useCallback(() => {
|
||||
const parts = canvasRef.current?.getManualRegions();
|
||||
if (!parts || parts.length === 0) {
|
||||
showToast('No lasso polygons to delete');
|
||||
return;
|
||||
}
|
||||
const last = parts[parts.length - 1];
|
||||
canvasRef.current?.deleteLasso(last.id);
|
||||
showToast(`Deleted lasso: ${last.regionName}`);
|
||||
}, [showToast]);
|
||||
|
||||
const handleGetLassoRegions = useCallback(() => {
|
||||
const parts = canvasRef.current?.getManualRegions();
|
||||
if (!parts || parts.length === 0) {
|
||||
Alert.alert('Manual Regions', 'No manual wall partitions available.');
|
||||
return;
|
||||
}
|
||||
const summary = parts
|
||||
.map(
|
||||
(p: ManualWallPartition) =>
|
||||
` ${p.regionName}: area=${p.area}, bbox=(${p.bbox.x},${p.bbox.y} ${p.bbox.w}x${p.bbox.h})`,
|
||||
)
|
||||
.join('\n');
|
||||
Alert.alert('Manual Wall Partitions', `${parts.length} regions:\n${summary}`);
|
||||
console.log(
|
||||
'[Example] getManualRegions:',
|
||||
JSON.stringify(
|
||||
parts.map(p => ({
|
||||
id: p.id,
|
||||
regionId: p.regionId,
|
||||
regionName: p.regionName,
|
||||
area: p.area,
|
||||
bbox: p.bbox,
|
||||
vertexCount: p.vertices.length,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// render: error / loading / ready
|
||||
// --------------------------------------------------------------------------
|
||||
@ -381,13 +458,65 @@ function App(): React.JSX.Element {
|
||||
(split walls)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeChip, manualSplitWalls && styles.modeChipActive]}
|
||||
onPress={() => {
|
||||
setManualSplitWalls(v => !v);
|
||||
if (!manualSplitWalls) {
|
||||
setSplitWalls(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.modeChipText, manualSplitWalls && styles.modeChipTextActive]}>
|
||||
(manual split)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeChip, magneticLasso && styles.modeChipLassoActive]}
|
||||
onPress={() => {
|
||||
setMagneticLasso(v => !v);
|
||||
if (!magneticLasso) {
|
||||
setManualSplitWalls(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.modeChipText, magneticLasso && styles.modeChipTextActive]}>
|
||||
(magnetic)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeChip, activeContourRefine && styles.modeChipLassoActive]}
|
||||
onPress={() => {
|
||||
setActiveContourRefine(v => !v);
|
||||
if (!activeContourRefine) {
|
||||
setManualSplitWalls(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.modeChipText, activeContourRefine && styles.modeChipTextActive]}>
|
||||
(contour)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeChip, splitEdgeBarrier && styles.modeChipActive]}
|
||||
onPress={() => {
|
||||
setSplitEdgeBarrier(v => !v);
|
||||
if (!splitEdgeBarrier) {
|
||||
setSplitWalls(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.modeChipText, splitEdgeBarrier && styles.modeChipTextActive]}>
|
||||
(edge barrier)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* canvas */}
|
||||
<View style={styles.canvasHost}>
|
||||
<MaskSegmentCanvas
|
||||
key={`image-group-${groupIndex}-split-${splitWalls ? 1 : 0}`}
|
||||
key={`image-group-${groupIndex}-split-${splitWalls ? 1 : 0}-manual-${manualSplitWalls ? 1 : 0}-magnetic-${magneticLasso ? 1 : 0}-contour-${activeContourRefine ? 1 : 0}-ebarrier-${splitEdgeBarrier ? 1 : 0}`}
|
||||
ref={canvasRef}
|
||||
style={styles.canvas}
|
||||
originUrl={imagePaths.origin}
|
||||
@ -399,6 +528,11 @@ function App(): React.JSX.Element {
|
||||
...DEFAULT_MASK_CONFIG,
|
||||
maxRegionColors: 6,
|
||||
splitWalls,
|
||||
manualSplitWalls,
|
||||
manualSplitWallsMaxCount: 8,
|
||||
magneticLasso,
|
||||
activeContourRefine,
|
||||
splitWallsEdgeBarrierThreshold: splitEdgeBarrier ? 160 : 0,
|
||||
}}
|
||||
paintConfig={{
|
||||
...DEFAULT_PAINT_CONFIG,
|
||||
@ -493,6 +627,46 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<Text style={styles.actionBtnText}>Export session</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Lasso operations */}
|
||||
<Text style={styles.sectionLabel}>Lasso:</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, isLassoing && styles.actionBtnLassoActive]}
|
||||
onPress={handleStartLasso}
|
||||
disabled={!isInteractive || !manualSplitWalls || isLassoing}
|
||||
>
|
||||
<Text style={styles.actionBtnText}>Start Lasso</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.actionBtnPrimary]}
|
||||
onPress={handleEndLasso}
|
||||
disabled={!isInteractive || !manualSplitWalls || !isLassoing}
|
||||
>
|
||||
<Text style={styles.actionBtnTextPrimary}>End Lasso</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.actionBtnDanger]}
|
||||
onPress={handleCancelLasso}
|
||||
disabled={!isInteractive || !manualSplitWalls || !isLassoing}
|
||||
>
|
||||
<Text style={styles.actionBtnText}>Cancel Lasso</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.actionBtnDanger]}
|
||||
onPress={handleDeleteLasso}
|
||||
disabled={!isInteractive || !manualSplitWalls}
|
||||
>
|
||||
<Text style={styles.actionBtnText}>Del Lasso</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.actionBtn}
|
||||
onPress={handleGetLassoRegions}
|
||||
disabled={!isInteractive || !manualSplitWalls}
|
||||
>
|
||||
<Text style={styles.actionBtnText}>Get Regions</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@ -585,6 +759,9 @@ const styles = StyleSheet.create({
|
||||
modeChipActive: {
|
||||
backgroundColor: '#4363D8',
|
||||
},
|
||||
modeChipLassoActive: {
|
||||
backgroundColor: '#00C853',
|
||||
},
|
||||
modeChipText: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
@ -697,6 +874,10 @@ const styles = StyleSheet.create({
|
||||
actionBtnDanger: {
|
||||
borderColor: '#e88',
|
||||
},
|
||||
actionBtnLassoActive: {
|
||||
backgroundColor: '#FF6B35',
|
||||
borderColor: '#FF6B35',
|
||||
},
|
||||
actionBtnText: {
|
||||
fontSize: 12,
|
||||
color: '#555',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "react-native-mask-segment-canvas",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "React Native mask segmentation canvas library: OpenCV semantic segmentation + SkSL Shader coloring",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,33 @@ export type MaskSegmentConfig = {
|
||||
splitWallsChromaBlurRadius?: number;
|
||||
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
||||
splitWallsNeutralChromaMax?: number;
|
||||
/**
|
||||
* Wall split: raw per-channel BGR Sobel gradient threshold for edge barriers
|
||||
* (0 = disabled). Uses un-normalized 8‑bit Sobel magnitude on each B/G/R
|
||||
* channel (max per pixel, range ≈ 0–1442). Visible wall seams ≈ 120–280,
|
||||
* subtle lighting gradients ≈ 20–80. Default: 160.
|
||||
*/
|
||||
splitWallsEdgeBarrierThreshold?: number;
|
||||
/**
|
||||
* Morphological close radius for wall mask holes before component labeling.
|
||||
* Non-wall pixels inside the wall boundary (windows, doors, mask artefacts)
|
||||
* are temporarily filled so the BFS can bridge across them. Default: 3.
|
||||
* Set to 0 to disable.
|
||||
*/
|
||||
splitWallsCloseMaskRadius?: number;
|
||||
/** When true, disables automatic texture-based wall splitting (splitWalls). Manual lasso partitioning is used instead. */
|
||||
manualSplitWalls?: boolean;
|
||||
/** wall mask only, max number of manual wall sub-regions defined by lasso */
|
||||
manualSplitWallsMaxCount?: number;
|
||||
/**
|
||||
* Manual lasso: morphological dilation radius (seg pixels) used to merge thin
|
||||
* unassigned wall pockets adjacent to the drawn polygon.
|
||||
*/
|
||||
manualSplitWallsGapAbsorbDilatePx?: number;
|
||||
/** When true, lasso mode uses edge-snapping (Sobel gradient + Dijkstra shortest-path). Default: false. */
|
||||
magneticLasso?: boolean;
|
||||
/** After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges. Default: false. */
|
||||
activeContourRefine?: boolean;
|
||||
};
|
||||
|
||||
export type PaintConfig = {
|
||||
@ -138,6 +165,23 @@ export type PaintBrushRequiredPayload = {
|
||||
|
||||
export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload;
|
||||
|
||||
/** A lasso polygon defined by user tapping vertices on the wall mask area. Vertices are in normalized image coordinates (0..1). */
|
||||
export type LassoPolygon = {
|
||||
id: string;
|
||||
vertices: { x: number; y: number }[];
|
||||
isClosed: boolean;
|
||||
};
|
||||
|
||||
/** Result of converting a lasso polygon into an actual wall sub-region. */
|
||||
export type ManualWallPartition = {
|
||||
id: string;
|
||||
regionId: number;
|
||||
regionName: string;
|
||||
vertices: { x: number; y: number }[];
|
||||
bbox: { x: number; y: number; w: number; h: number };
|
||||
area: number;
|
||||
};
|
||||
|
||||
export type MaskSegmentCanvasRef = {
|
||||
reset: () => void;
|
||||
swap: (showOrigin?: boolean) => void;
|
||||
@ -154,6 +198,20 @@ export type MaskSegmentCanvasRef = {
|
||||
getPaintedRegions: () => PaintedRegionRecord[];
|
||||
/** Returns the most recent auto-export or save() result, if any. */
|
||||
getLastExport?: () => SavePaintResult | null;
|
||||
/** Enter lasso mode — user can tap wall mask area to place polygon vertices. */
|
||||
startLasso: () => void;
|
||||
/** Exit lasso mode, convert all closed lasso polygons into wall-X sub-regions for painting. Returns the partition results. */
|
||||
endLasso: () => ManualWallPartition[];
|
||||
/**
|
||||
* Exit the current lasso editing session without saving regions.
|
||||
* Discards in-progress vertices and closed polygons from this session only;
|
||||
* previously committed manual wall partitions are kept.
|
||||
*/
|
||||
cancelLasso: () => void;
|
||||
/** Get the current manual wall partitions (only valid after endLasso has been called). */
|
||||
getManualRegions: () => ManualWallPartition[];
|
||||
/** Delete a lasso polygon by its id. Committed partitions also drop paint on that region. */
|
||||
deleteLasso: (id: string) => void;
|
||||
};
|
||||
|
||||
export type MaskSegmentCanvasProps = {
|
||||
|
||||
@ -2,6 +2,8 @@ export { default } from './components/MaskSegmentCanvas';
|
||||
export type {
|
||||
BgrColor,
|
||||
InteractionConfig,
|
||||
LassoPolygon,
|
||||
ManualWallPartition,
|
||||
MaskSegmentCanvasProps,
|
||||
MaskSegmentCanvasRef,
|
||||
MaskSegmentConfig,
|
||||
|
||||
380
src/utils/activeContour.ts
Normal file
380
src/utils/activeContour.ts
Normal file
@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Active Contour Model — greedy snake + balloon force.
|
||||
*
|
||||
* After the user finishes a lasso polygon, this module refines the boundary
|
||||
* vertices outward toward the true wall-mask edge. Each vertex samples
|
||||
* positions along its outward normal and picks the one with lowest energy.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Subdivide polygon to get evenly-spaced control points
|
||||
* 2. For each iteration (3-5 rounds):
|
||||
* a. Compute outward normal at each point
|
||||
* b. Sample N positions along the normal (outward first, then inward)
|
||||
* c. Score each position: E = E_edge + E_smooth
|
||||
* d. Move vertex to min-energy position (constrained to wall mask)
|
||||
* 3. Douglas-Peucker simplify
|
||||
*/
|
||||
|
||||
import {
|
||||
isNormPointOnWallMask,
|
||||
type WallMaskSample,
|
||||
} from './magneticLasso';
|
||||
|
||||
/* ==========================================================================
|
||||
* Types
|
||||
* ========================================================================== */
|
||||
|
||||
export type ActiveContourOpts = {
|
||||
/** Number of greedy iterations (default 3). */
|
||||
iterations?: number;
|
||||
/** Number of sample positions along normal per direction (default 6). */
|
||||
samplesPerDirection?: number;
|
||||
/** Step size (norm coords) between samples (default 0.003). */
|
||||
sampleStep?: number;
|
||||
/** Smoothness weight — higher keeps vertices more uniformly spaced (default 0.15). */
|
||||
smoothWeight?: number;
|
||||
/** Edge weight — higher makes contour hug mask boundary (default 1.0). */
|
||||
edgeWeight?: number;
|
||||
/** Balloon bias — extra outward push per iteration (default 0.002). */
|
||||
balloonForce?: number;
|
||||
/** Minimum vertex count for a polygon to be refined (default 4). */
|
||||
minVertices?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTS: Required<ActiveContourOpts> = {
|
||||
iterations: 3,
|
||||
samplesPerDirection: 6,
|
||||
sampleStep: 0.003,
|
||||
smoothWeight: 0.15,
|
||||
edgeWeight: 1.0,
|
||||
balloonForce: 0.002,
|
||||
minVertices: 4,
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
* Helpers
|
||||
* ========================================================================== */
|
||||
|
||||
function computeOutwardNormal(
|
||||
prev: { x: number; y: number },
|
||||
curr: { x: number; y: number },
|
||||
next: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const dx1 = curr.x - prev.x;
|
||||
const dy1 = curr.y - prev.y;
|
||||
const dx2 = next.x - curr.x;
|
||||
const dy2 = next.y - curr.y;
|
||||
|
||||
// Average tangent direction at curr
|
||||
const tx = dx1 + dx2;
|
||||
const ty = dy1 + dy2;
|
||||
|
||||
// Normal (rotate 90° CCW) — two candidates
|
||||
const nx1 = -ty;
|
||||
const ny1 = tx;
|
||||
const nx2 = ty;
|
||||
const ny2 = -tx;
|
||||
|
||||
// Choose the outward normal: the one that points away from centroid
|
||||
// A simple heuristic: the direction with positive dot product with
|
||||
// (curr - centroid). Since we can't compute centroid cheaply each time,
|
||||
// use a nearby point approximation: the direction that points to larger
|
||||
// edge energy (i.e., toward wall boundary). We'll pick the direction
|
||||
// that pushes the polygon out.
|
||||
//
|
||||
// For now we use the convention: the normal pointing toward positive
|
||||
// sweep (CCW polygon → normal should point outward).
|
||||
// We'll verify by computing the cross product of the normal with the
|
||||
// edge direction.
|
||||
const cross1 = dx2 * ny1 - dy2 * nx1;
|
||||
const cross2 = dx2 * ny2 - dy2 * nx2;
|
||||
|
||||
const len = Math.hypot(nx1, ny1);
|
||||
if (len < 1e-8) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// For a CCW polygon, the outward normal is the one with negative cross product
|
||||
const [nx, ny] = cross1 < cross2 ? [nx1, ny1] : [nx2, ny2];
|
||||
|
||||
return {
|
||||
x: nx / len,
|
||||
y: ny / len,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance to the nearest wall-mask boundary pixel.
|
||||
* Returns [0..∞) in normalized coordinate space.
|
||||
* Uses a fast spiral search within maxRadius.
|
||||
*/
|
||||
function distToWallBoundary(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
maxRadius: number,
|
||||
): number {
|
||||
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||
if (cols <= 0 || rows <= 0) return maxRadius;
|
||||
|
||||
const cx = Math.round(normX * cols);
|
||||
const cy = Math.round(normY * rows);
|
||||
const r = Math.ceil(maxRadius);
|
||||
let best = maxRadius + 1;
|
||||
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= cols || y >= rows) continue;
|
||||
const i = y * cols + x;
|
||||
if (labels[i] !== wallSemanticIdx || baseboardBinary[i]) continue;
|
||||
// Check if this is a boundary pixel (adjacent to non-wall)
|
||||
if (
|
||||
x === 0 || y === 0 || x === cols - 1 || y === rows - 1 ||
|
||||
labels[i - 1] !== wallSemanticIdx ||
|
||||
labels[i + 1] !== wallSemanticIdx ||
|
||||
labels[i - cols] !== wallSemanticIdx ||
|
||||
labels[i + cols] !== wallSemanticIdx
|
||||
) {
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < best) best = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert from seg pixels to normalized space
|
||||
return best / Math.max(cols, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a candidate position:
|
||||
* E = edgeWeight * edgeEnergy + smoothWeight * smoothEnergy - balloonForce
|
||||
*
|
||||
* Lower score = better.
|
||||
* Edge energy is distance to nearest wall boundary (0 = on boundary).
|
||||
* Smooth energy penalizes large deviations from the median of neighbors.
|
||||
*/
|
||||
function scorePosition(
|
||||
nx: number,
|
||||
ny: number,
|
||||
mask: WallMaskSample,
|
||||
maxEdgeDist: number,
|
||||
neighbors: { x: number; y: number }[],
|
||||
opts: Required<ActiveContourOpts>,
|
||||
): number {
|
||||
if (!isNormPointOnWallMask(nx, ny, mask)) {
|
||||
return 1e6; // reject
|
||||
}
|
||||
|
||||
const edge = distToWallBoundary(nx, ny, mask, maxEdgeDist) / maxEdgeDist;
|
||||
|
||||
let smooth = 0;
|
||||
if (neighbors.length >= 2) {
|
||||
const n0 = neighbors[0];
|
||||
const n1 = neighbors[neighbors.length - 1];
|
||||
const mx = (n0.x + n1.x) / 2;
|
||||
const my = (n0.y + n1.y) / 2;
|
||||
smooth = Math.hypot(nx - mx, ny - my);
|
||||
}
|
||||
|
||||
return opts.edgeWeight * edge + opts.smoothWeight * smooth;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Subdivision — insert points where consecutive vertices are far apart
|
||||
* ========================================================================== */
|
||||
|
||||
function subdividePolygon(
|
||||
vertices: { x: number; y: number }[],
|
||||
maxGap: number,
|
||||
): { x: number; y: number }[] {
|
||||
if (vertices.length < 2) return [...vertices];
|
||||
const result: { x: number; y: number }[] = [];
|
||||
const n = vertices.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = vertices[i];
|
||||
const b = vertices[(i + 1) % n];
|
||||
result.push({ ...a });
|
||||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const steps = Math.floor(dist / maxGap);
|
||||
if (steps > 1) {
|
||||
for (let s = 1; s < steps; s++) {
|
||||
const t = s / steps;
|
||||
result.push({
|
||||
x: a.x + t * (b.x - a.x),
|
||||
y: a.y + t * (b.y - a.y),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Main
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* Refine a single closed lasso polygon to hug the wall-mask outer boundary.
|
||||
*
|
||||
* Returns a new vertex list (not mutated in place). Returns the original
|
||||
* polygon unchanged if it has too few vertices or no wall mask is given.
|
||||
*/
|
||||
export function refinePolygonToWallEdges(
|
||||
vertices: { x: number; y: number }[],
|
||||
mask: WallMaskSample,
|
||||
opts?: ActiveContourOpts,
|
||||
): { x: number; y: number }[] {
|
||||
const o = { ...DEFAULT_OPTS, ...opts };
|
||||
if (vertices.length < o.minVertices || !mask) {
|
||||
return [...vertices];
|
||||
}
|
||||
|
||||
// 1. Subdivide to get evenly-spaced control points
|
||||
let points = subdividePolygon(vertices, o.sampleStep * 2);
|
||||
|
||||
// 2. Greedy iteration loop
|
||||
const maxEdgeDist = o.samplesPerDirection * o.sampleStep * Math.max(mask.cols, mask.rows);
|
||||
const segPxEdgeDist = o.samplesPerDirection * o.sampleStep * Math.max(mask.cols, mask.rows);
|
||||
|
||||
for (let iter = 0; iter < o.iterations; iter++) {
|
||||
const newPoints: { x: number; y: number }[] = [];
|
||||
const m = points.length;
|
||||
|
||||
for (let i = 0; i < m; i++) {
|
||||
const prev = points[(i - 1 + m) % m];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % m];
|
||||
|
||||
const normal = computeOutwardNormal(prev, curr, next);
|
||||
if (normal.x === 0 && normal.y === 0) {
|
||||
newPoints.push({ ...curr });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bestPt = { ...curr };
|
||||
let bestScore = scorePosition(
|
||||
curr.x, curr.y, mask, segPxEdgeDist,
|
||||
[prev, next], o,
|
||||
);
|
||||
|
||||
// Apply balloon force: bias outward by extra offset
|
||||
const balloonOffset = o.balloonForce * (iter + 1);
|
||||
|
||||
// Sample positions: outward first (balloon force region), then inward
|
||||
for (let d = 1; d <= o.samplesPerDirection; d++) {
|
||||
const dist = d * o.sampleStep;
|
||||
|
||||
// Outward (balloon direction)
|
||||
const nxOut = curr.x + normal.x * (dist + balloonOffset);
|
||||
const nyOut = curr.y + normal.y * (dist + balloonOffset);
|
||||
const scoreOut = scorePosition(
|
||||
nxOut, nyOut, mask, segPxEdgeDist,
|
||||
[newPoints.length > 0 ? newPoints[newPoints.length - 1] : points[(i - 1 + m) % m], next],
|
||||
o,
|
||||
);
|
||||
if (scoreOut < bestScore) {
|
||||
bestScore = scoreOut;
|
||||
bestPt = { x: nxOut, y: nyOut };
|
||||
}
|
||||
|
||||
// Inward (conservative)
|
||||
const nxIn = curr.x - normal.x * dist;
|
||||
const nyIn = curr.y - normal.y * dist;
|
||||
const scoreIn = scorePosition(
|
||||
nxIn, nyIn, mask, segPxEdgeDist,
|
||||
[newPoints.length > 0 ? newPoints[newPoints.length - 1] : points[(i - 1 + m) % m], next],
|
||||
o,
|
||||
);
|
||||
if (scoreIn < bestScore) {
|
||||
bestScore = scoreIn;
|
||||
bestPt = { x: nxIn, y: nyIn };
|
||||
}
|
||||
}
|
||||
|
||||
newPoints.push(bestPt);
|
||||
}
|
||||
|
||||
points = newPoints;
|
||||
}
|
||||
|
||||
// 3. Douglas-Peucker simplify
|
||||
const simplified = douglasPeucker(points, 0.002);
|
||||
if (simplified.length < 3) return [...vertices];
|
||||
|
||||
// Ensure closed
|
||||
const first = simplified[0];
|
||||
const last = simplified[simplified.length - 1];
|
||||
if (Math.hypot(first.x - last.x, first.y - last.y) > 0.0005) {
|
||||
simplified.push({ ...first });
|
||||
}
|
||||
|
||||
return simplified;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Douglas-Peucker (inlined for independence)
|
||||
* ========================================================================== */
|
||||
|
||||
function douglasPeucker(
|
||||
path: { x: number; y: number }[],
|
||||
epsilon: number,
|
||||
): { x: number; y: number }[] {
|
||||
if (path.length <= 2) return [...path];
|
||||
|
||||
const keep = new Uint8Array(path.length);
|
||||
keep[0] = 1;
|
||||
keep[path.length - 1] = 1;
|
||||
|
||||
function recurse(s: number, e: number) {
|
||||
if (e - s <= 1) return;
|
||||
const ax = path[s].x;
|
||||
const ay = path[s].y;
|
||||
const bx = path[e].x;
|
||||
const by = path[e].y;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
|
||||
let maxDist = 0;
|
||||
let maxIdx = s;
|
||||
for (let i = s + 1; i < e; i++) {
|
||||
let dist: number;
|
||||
if (lenSq === 0) {
|
||||
dist = Math.hypot(path[i].x - ax, path[i].y - ay);
|
||||
} else {
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((path[i].x - ax) * dx + (path[i].y - ay) * dy) / lenSq,
|
||||
));
|
||||
const px = ax + t * dx;
|
||||
const py = ay + t * dy;
|
||||
dist = Math.hypot(path[i].x - px, path[i].y - py);
|
||||
}
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
maxIdx = i;
|
||||
}
|
||||
}
|
||||
if (maxDist > epsilon) {
|
||||
keep[maxIdx] = 1;
|
||||
recurse(s, maxIdx);
|
||||
recurse(maxIdx, e);
|
||||
}
|
||||
}
|
||||
|
||||
recurse(0, path.length - 1);
|
||||
|
||||
// Enforce minimum distance between consecutive anchors
|
||||
const result: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (!keep[i]) continue;
|
||||
if (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
if (Math.hypot(path[i].x - last.x, path[i].y - last.y) < 0.001) continue;
|
||||
}
|
||||
result.push({ x: path[i].x, y: path[i].y });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
621
src/utils/magneticLasso.ts
Normal file
621
src/utils/magneticLasso.ts
Normal file
@ -0,0 +1,621 @@
|
||||
/**
|
||||
* Magnetic Lasso — edge-snapping polygon placement for manual wall splitting.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. buildEnergyMap → grayscale + downsample + Sobel gradient → energy grid
|
||||
* 2. findShortestPath → Dijkstra 8-connected on low-energy (edge) pixels
|
||||
* 3. extractCornerPoints → Douglas-Peucker simplification on raw path
|
||||
* 4. upscalePath → map energy-space coords back to original image coords
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
* Types
|
||||
* ========================================================================== */
|
||||
|
||||
export type EnergyMap = {
|
||||
/** Float32Array per-pixel energy values [0…1]; low = edge, high = flat */
|
||||
map: Float32Array;
|
||||
w: number;
|
||||
h: number;
|
||||
/** Downscale ratio: energyDim / sourceDim (≈ em.w / sourceCols) */
|
||||
scale: number;
|
||||
/** Optional 0/1 mask at energy resolution; 0 = blocked for pathfinding */
|
||||
traversable?: Uint8Array;
|
||||
};
|
||||
|
||||
/** Seg-resolution wall mask used to constrain lasso vertices. */
|
||||
export type WallMaskSample = {
|
||||
labels: Uint8Array;
|
||||
baseboardBinary: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
wallSemanticIdx: number;
|
||||
};
|
||||
|
||||
/** True when norm coords fall on a wall semantic pixel (excludes baseboard). */
|
||||
export function isNormPointOnWallMask(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
): boolean {
|
||||
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||
if (wallSemanticIdx < 0 || cols <= 0 || rows <= 0) {
|
||||
return false;
|
||||
}
|
||||
const cx = Math.min(cols - 1, Math.max(0, Math.floor(normX * cols)));
|
||||
const cy = Math.min(rows - 1, Math.max(0, Math.floor(normY * rows)));
|
||||
const i = cy * cols + cx;
|
||||
if (baseboardBinary[i]) {
|
||||
return false;
|
||||
}
|
||||
return labels[i] === wallSemanticIdx;
|
||||
}
|
||||
|
||||
export function filterVerticesToWallMask<T extends { x: number; y: number }>(
|
||||
vertices: T[],
|
||||
mask: WallMaskSample,
|
||||
): T[] {
|
||||
return vertices.filter(v => isNormPointOnWallMask(v.x, v.y, mask));
|
||||
}
|
||||
|
||||
function isWallPixel(mask: WallMaskSample, x: number, y: number): boolean {
|
||||
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||
if (x < 0 || y < 0 || x >= cols || y >= rows || wallSemanticIdx < 0) {
|
||||
return false;
|
||||
}
|
||||
const i = y * cols + x;
|
||||
if (baseboardBinary[i]) {
|
||||
return false;
|
||||
}
|
||||
return labels[i] === wallSemanticIdx;
|
||||
}
|
||||
|
||||
function isWallBoundaryPixel(mask: WallMaskSample, x: number, y: number): boolean {
|
||||
if (!isWallPixel(mask, x, y)) {
|
||||
return false;
|
||||
}
|
||||
const { cols, rows } = mask;
|
||||
if (x === 0 || y === 0 || x === cols - 1 || y === rows - 1) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!isWallPixel(mask, x - 1, y) ||
|
||||
!isWallPixel(mask, x + 1, y) ||
|
||||
!isWallPixel(mask, x, y - 1) ||
|
||||
!isWallPixel(mask, x, y + 1)
|
||||
);
|
||||
}
|
||||
|
||||
function isWallCornerBoundaryPixel(
|
||||
mask: WallMaskSample,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
if (!isWallBoundaryPixel(mask, x, y)) {
|
||||
return false;
|
||||
}
|
||||
const left = !isWallPixel(mask, x - 1, y);
|
||||
const right = !isWallPixel(mask, x + 1, y);
|
||||
const up = !isWallPixel(mask, x, y - 1);
|
||||
const down = !isWallPixel(mask, x, y + 1);
|
||||
return (left || right) && (up || down);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a normalized point to the nearest wall-mask boundary pixel when the
|
||||
* touch falls within `snapRadiusSegPx` (segmentation resolution) of the edge.
|
||||
*/
|
||||
export function snapNormPointToWallEdge(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
snapRadiusSegPx = 12,
|
||||
): { x: number; y: number } {
|
||||
const snapped = searchWallSnapTarget(
|
||||
normX, normY, mask, snapRadiusSegPx, 'edge',
|
||||
);
|
||||
return snapped ?? { x: normX, y: normY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer wall-mask corner pixels (L-shaped outer boundary), then plain edge.
|
||||
* Used when the user taps without dragging.
|
||||
*/
|
||||
export function snapNormPointToWallCornerOrEdge(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
snapRadiusSegPx = 16,
|
||||
): { x: number; y: number } {
|
||||
const corner = searchWallSnapTarget(
|
||||
normX, normY, mask, snapRadiusSegPx, 'corner',
|
||||
);
|
||||
if (corner) {
|
||||
return corner;
|
||||
}
|
||||
const edge = searchWallSnapTarget(
|
||||
normX, normY, mask, snapRadiusSegPx, 'edge',
|
||||
);
|
||||
return edge ?? { x: normX, y: normY };
|
||||
}
|
||||
|
||||
/**
|
||||
* During vertex drag: snap to corner/edge when near, otherwise keep interior
|
||||
* wall points so the anchor can move freely on the wall mask.
|
||||
*/
|
||||
export function resolveLassoWallDragPoint(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
snapRadiusSegPx = 12,
|
||||
): { x: number; y: number } | null {
|
||||
const snapped = snapNormPointToWallCornerOrEdge(
|
||||
normX, normY, mask, snapRadiusSegPx,
|
||||
);
|
||||
if (isNormPointOnWallMask(snapped.x, snapped.y, mask)) {
|
||||
return snapped;
|
||||
}
|
||||
if (isNormPointOnWallMask(normX, normY, mask)) {
|
||||
return { x: normX, y: normY };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function searchWallSnapTarget(
|
||||
normX: number,
|
||||
normY: number,
|
||||
mask: WallMaskSample,
|
||||
snapRadiusSegPx: number,
|
||||
mode: 'corner' | 'edge',
|
||||
): { x: number; y: number } | null {
|
||||
const { cols, rows } = mask;
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const px = normX * cols;
|
||||
const py = normY * rows;
|
||||
const cx = Math.floor(px);
|
||||
const cy = Math.floor(py);
|
||||
const radius = Math.max(1, Math.ceil(snapRadiusSegPx));
|
||||
const radiusSq = snapRadiusSegPx * snapRadiusSegPx;
|
||||
|
||||
let bestDistSq = Infinity;
|
||||
let bestX = -1;
|
||||
let bestY = -1;
|
||||
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= cols || y >= rows) {
|
||||
continue;
|
||||
}
|
||||
const onEdge = isWallBoundaryPixel(mask, x, y);
|
||||
if (!onEdge) {
|
||||
continue;
|
||||
}
|
||||
if (mode === 'corner' && !isWallCornerBoundaryPixel(mask, x, y)) {
|
||||
continue;
|
||||
}
|
||||
const distSq = (px - (x + 0.5)) ** 2 + (py - (y + 0.5)) ** 2;
|
||||
if (distSq <= radiusSq && distSq < bestDistSq) {
|
||||
bestDistSq = distSq;
|
||||
bestX = x;
|
||||
bestY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestX < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (bestX + 0.5) / cols,
|
||||
y: (bestY + 0.5) / rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWallAllowedMask(
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
wallSemanticIdx: number,
|
||||
): Uint8Array | null {
|
||||
if (wallSemanticIdx < 0) {
|
||||
return null;
|
||||
}
|
||||
const allowedMask = new Uint8Array(labels.length);
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
allowedMask[i] =
|
||||
labels[i] === wallSemanticIdx && !baseboardBinary[i] ? 1 : 0;
|
||||
}
|
||||
return allowedMask;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* buildEnergyMap
|
||||
* ========================================================================== */
|
||||
|
||||
const GRAY_R = 0.299;
|
||||
const GRAY_G = 0.587;
|
||||
const GRAY_B = 0.114;
|
||||
|
||||
/**
|
||||
* Build per-pixel energy map from BGR buffer.
|
||||
* 1. Convert to grayscale via luminance weights
|
||||
* 2. Downsample so longest side ≤ targetMaxSide
|
||||
* 3. Apply Sobel 3×3 → gradient magnitude G
|
||||
* 4. Energy = 1 / (1 + G), clamped to [0, 1]
|
||||
*/
|
||||
export function buildEnergyMap(
|
||||
bgrBuffer: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
targetMaxSide = 256,
|
||||
allowedMask?: Uint8Array | null,
|
||||
): EnergyMap {
|
||||
const imgLongSide = Math.max(cols, rows);
|
||||
const scale = imgLongSide > targetMaxSide ? targetMaxSide / imgLongSide : 1;
|
||||
const ew = Math.max(1, Math.floor(cols * scale));
|
||||
const eh = Math.max(1, Math.floor(rows * scale));
|
||||
const pixelCount = ew * eh;
|
||||
|
||||
// 1. Build grayscale at target resolution (nearest-neighbour downsample)
|
||||
const gray = new Float32Array(pixelCount);
|
||||
for (let gy = 0; gy < eh; gy++) {
|
||||
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||
const rowBase = sy * cols;
|
||||
for (let gx = 0; gx < ew; gx++) {
|
||||
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||
const i = rowBase + sx;
|
||||
const o = (i) * 3;
|
||||
const val =
|
||||
GRAY_R * bgrBuffer[o + 2] +
|
||||
GRAY_G * bgrBuffer[o + 1] +
|
||||
GRAY_B * bgrBuffer[o];
|
||||
gray[gy * ew + gx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sobel 3×3 → gradient magnitude
|
||||
// X: [-1 0 1; -2 0 2; -1 0 1]
|
||||
// Y: [-1 -2 -1; 0 0 0; 1 2 1]
|
||||
const grad = new Float32Array(pixelCount);
|
||||
let maxG = 1; // avoid division by zero
|
||||
|
||||
for (let gy = 1; gy < eh - 1; gy++) {
|
||||
for (let gx = 1; gx < ew - 1; gx++) {
|
||||
const idx = gy * ew + gx;
|
||||
const a = gray[(gy - 1) * ew + (gx - 1)];
|
||||
const b = gray[(gy - 1) * ew + gx];
|
||||
const c = gray[(gy - 1) * ew + (gx + 1)];
|
||||
const d = gray[gy * ew + (gx - 1)];
|
||||
const e = gray[gy * ew + gx + 1];
|
||||
const f = gray[(gy + 1) * ew + (gx - 1)];
|
||||
const gv = gray[(gy + 1) * ew + gx];
|
||||
const h = gray[(gy + 1) * ew + (gx + 1)];
|
||||
|
||||
const gxVal = -a + c - 2 * d + 2 * e - f + h;
|
||||
const gyVal = -a - 2 * b - c + f + 2 * gv + h;
|
||||
const mag = Math.sqrt(gxVal * gxVal + gyVal * gyVal);
|
||||
grad[idx] = mag;
|
||||
if (mag > maxG) maxG = mag;
|
||||
}
|
||||
}
|
||||
|
||||
// Boost wall-mask boundary so paths hug the semantic wall edge, not just texture.
|
||||
if (allowedMask && allowedMask.length === cols * rows) {
|
||||
for (let gy = 0; gy < eh; gy++) {
|
||||
for (let gx = 0; gx < ew; gx++) {
|
||||
const idx = gy * ew + gx;
|
||||
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||
if (!allowedMask[sy * cols + sx]) continue;
|
||||
|
||||
let onBoundary = sx === 0 || sy === 0 || sx === cols - 1 || sy === rows - 1;
|
||||
if (!onBoundary) {
|
||||
const n1 = allowedMask[sy * cols + (sx - 1)];
|
||||
const n2 = allowedMask[sy * cols + (sx + 1)];
|
||||
const n3 = allowedMask[(sy - 1) * cols + sx];
|
||||
const n4 = allowedMask[(sy + 1) * cols + sx];
|
||||
onBoundary = n1 === 0 || n2 === 0 || n3 === 0 || n4 === 0;
|
||||
}
|
||||
if (onBoundary) {
|
||||
grad[idx] = maxG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Energy = 1 / (1 + normalized_gradient); amplify contrast so edges win over interior shortcuts.
|
||||
const energy = new Float32Array(pixelCount);
|
||||
const traversable =
|
||||
allowedMask && allowedMask.length === cols * rows
|
||||
? new Uint8Array(pixelCount)
|
||||
: undefined;
|
||||
|
||||
for (let gy = 0; gy < eh; gy++) {
|
||||
for (let gx = 0; gx < ew; gx++) {
|
||||
const idx = gy * ew + gx;
|
||||
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||
const allowed = !allowedMask || allowedMask[sy * cols + sx] > 0;
|
||||
if (traversable) {
|
||||
traversable[idx] = allowed ? 1 : 0;
|
||||
}
|
||||
energy[idx] = allowed ? 1.0 / (1.0 + 4.0 * (grad[idx] / maxG)) : 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
return { map: energy, w: ew, h: eh, scale, traversable };
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* findShortestPath — Dijkstra 8-connected
|
||||
* ========================================================================== */
|
||||
|
||||
/** 8-connected neighbour offsets (dx, dy) */
|
||||
const NEIGHBOURS: [number, number][] = [
|
||||
[-1, -1], [0, -1], [1, -1],
|
||||
[-1, 0], /* */ [1, 0],
|
||||
[-1, 1], [0, 1], [1, 1],
|
||||
];
|
||||
|
||||
/** Multiply energy cost so we can use integer priority keys. */
|
||||
const COST_SCALE = 10000;
|
||||
|
||||
/** Binary min-heap for Dijkstra priority queue. */
|
||||
class MinHeap {
|
||||
private data: { idx: number; dist: number }[] = [];
|
||||
|
||||
push(idx: number, dist: number): void {
|
||||
this.data.push({ idx, dist });
|
||||
this.bubbleUp(this.data.length - 1);
|
||||
}
|
||||
|
||||
pop(): { idx: number; dist: number } | undefined {
|
||||
if (this.data.length === 0) return undefined;
|
||||
const top = this.data[0];
|
||||
const last = this.data.pop()!;
|
||||
if (this.data.length > 0) {
|
||||
this.data[0] = last;
|
||||
this.bubbleDown(0);
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
private bubbleUp(i: number): void {
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.data[parent].dist <= this.data[i].dist) break;
|
||||
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
private bubbleDown(i: number): void {
|
||||
const n = this.data.length;
|
||||
while (true) {
|
||||
let smallest = i;
|
||||
const left = 2 * i + 1;
|
||||
const right = 2 * i + 2;
|
||||
if (left < n && this.data[left].dist < this.data[smallest].dist) smallest = left;
|
||||
if (right < n && this.data[right].dist < this.data[smallest].dist) smallest = right;
|
||||
if (smallest === i) break;
|
||||
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentry value for "not visited" */
|
||||
const DIST_INF = 0xffffffff;
|
||||
|
||||
/**
|
||||
* Dijkstra shortest-path on 8-connected grid.
|
||||
* Cost at each pixel = energy[pixel] * COST_SCALE (integer).
|
||||
* Diagonal steps cost √2 × the neighbour's energy.
|
||||
*
|
||||
* Returns ordered path [start, …, end] in energy-map pixel space.
|
||||
*/
|
||||
export function findShortestPath(
|
||||
energy: Float32Array,
|
||||
energyW: number,
|
||||
energyH: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
ex: number,
|
||||
ey: number,
|
||||
traversable?: Uint8Array | null,
|
||||
): { x: number; y: number }[] {
|
||||
// Clamp to valid range
|
||||
const clampX = (v: number) => Math.max(1, Math.min(energyW - 2, Math.round(v)));
|
||||
const clampY = (v: number) => Math.max(1, Math.min(energyH - 2, Math.round(v)));
|
||||
|
||||
const startX = clampX(sx);
|
||||
const startY = clampY(sy);
|
||||
const endX = clampX(ex);
|
||||
const endY = clampY(ey);
|
||||
|
||||
const pixelCount = energyW * energyH;
|
||||
const startIdx = startY * energyW + startX;
|
||||
|
||||
// Distance array (init to infinity)
|
||||
const dist = new Uint32Array(pixelCount);
|
||||
dist.fill(DIST_INF);
|
||||
dist[startIdx] = 0;
|
||||
|
||||
// Previous node for path reconstruction
|
||||
const prev = new Int32Array(pixelCount);
|
||||
prev.fill(-1);
|
||||
|
||||
const heap = new MinHeap();
|
||||
heap.push(startIdx, 0);
|
||||
|
||||
while (heap.length > 0) {
|
||||
const node = heap.pop()!;
|
||||
const u = node.idx;
|
||||
const d = node.dist;
|
||||
if (d > dist[u]) continue;
|
||||
|
||||
const ux = u % energyW;
|
||||
const uy = Math.floor(u / energyW);
|
||||
|
||||
if (ux === endX && uy === endY) {
|
||||
const path: { x: number; y: number }[] = [];
|
||||
let cur = u;
|
||||
while (cur >= 0) {
|
||||
path.push({ x: cur % energyW, y: Math.floor(cur / energyW) });
|
||||
cur = prev[cur];
|
||||
}
|
||||
path.reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
for (const [dx, dy] of NEIGHBOURS) {
|
||||
const nx = ux + dx;
|
||||
const ny = uy + dy;
|
||||
if (nx < 0 || nx >= energyW || ny < 0 || ny >= energyH) continue;
|
||||
const v = ny * energyW + nx;
|
||||
if (traversable && traversable[v] === 0) continue;
|
||||
|
||||
const isDiagonal = dx !== 0 && dy !== 0;
|
||||
const stepCost = isDiagonal
|
||||
? Math.round(energy[v] * COST_SCALE * 1.4142)
|
||||
: Math.round(energy[v] * COST_SCALE);
|
||||
|
||||
const alt = d + stepCost;
|
||||
if (alt < dist[v]) {
|
||||
dist[v] = alt;
|
||||
prev[v] = u;
|
||||
heap.push(v, alt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No path found — return straight line
|
||||
return [{ x: startX, y: startY }, { x: endX, y: endY }];
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* extractCornerPoints — Douglas-Peucker simplification
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* Douglas-Peucker simplification. Keeps points where the perpendicular
|
||||
* distance from the line segment exceeds epsilon.
|
||||
*
|
||||
* After DP, also enforces a minimum distance between consecutive anchors
|
||||
* to avoid overly dense clusters.
|
||||
*/
|
||||
export function extractCornerPoints(
|
||||
path: { x: number; y: number }[],
|
||||
minDistance = 8,
|
||||
epsilon = 2.0,
|
||||
): { x: number; y: number }[] {
|
||||
if (path.length <= 2) return [...path];
|
||||
|
||||
const keep = new Uint8Array(path.length);
|
||||
keep[0] = 1;
|
||||
keep[path.length - 1] = 1;
|
||||
|
||||
function recurse(s: number, e: number) {
|
||||
if (e - s <= 1) return;
|
||||
|
||||
const ax = path[s].x;
|
||||
const ay = path[s].y;
|
||||
const bx = path[e].x;
|
||||
const by = path[e].y;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
|
||||
let maxDist = 0;
|
||||
let maxIdx = s;
|
||||
|
||||
for (let i = s + 1; i < e; i++) {
|
||||
let dist: number;
|
||||
if (lenSq === 0) {
|
||||
dist = Math.hypot(path[i].x - ax, path[i].y - ay);
|
||||
} else {
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((path[i].x - ax) * dx + (path[i].y - ay) * dy) / lenSq,
|
||||
));
|
||||
const px = ax + t * dx;
|
||||
const py = ay + t * dy;
|
||||
dist = Math.hypot(path[i].x - px, path[i].y - py);
|
||||
}
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
maxIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilon) {
|
||||
keep[maxIdx] = 1;
|
||||
recurse(s, maxIdx);
|
||||
recurse(maxIdx, e);
|
||||
}
|
||||
}
|
||||
|
||||
recurse(0, path.length - 1);
|
||||
|
||||
// Collect kept points with minDistance filter
|
||||
const result: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (!keep[i]) continue;
|
||||
if (result.length > 0) {
|
||||
const last = result[result.length - 1];
|
||||
const dist = Math.hypot(path[i].x - last.x, path[i].y - last.y);
|
||||
if (dist < minDistance) continue;
|
||||
}
|
||||
result.push({ x: path[i].x, y: path[i].y });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* upscalePath
|
||||
* ========================================================================== */
|
||||
|
||||
/** Map normalized image coords (0..1) to energy-map pixel coords. */
|
||||
export function normToEnergyPoint(
|
||||
normX: number,
|
||||
normY: number,
|
||||
em: EnergyMap,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.min(em.w - 1, Math.max(0, normX * em.w)),
|
||||
y: Math.min(em.h - 1, Math.max(0, normY * em.h)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Map energy-map pixel coords back to normalized image coords. */
|
||||
export function energyPointsToNorm(
|
||||
points: { x: number; y: number }[],
|
||||
em: EnergyMap,
|
||||
): { x: number; y: number }[] {
|
||||
return points.map(p => ({
|
||||
x: Math.min(1, Math.max(0, p.x / em.w)),
|
||||
y: Math.min(1, Math.max(0, p.y / em.h)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Map energy-map pixel coords back to original image coords. */
|
||||
export function upscalePath(
|
||||
points: { x: number; y: number }[],
|
||||
scale: number,
|
||||
originW: number,
|
||||
originH: number,
|
||||
): { x: number; y: number }[] {
|
||||
return points.map(p => ({
|
||||
x: Math.min(originW - 1, Math.max(0, Math.round(p.x / scale))),
|
||||
y: Math.min(originH - 1, Math.max(0, Math.round(p.y / scale))),
|
||||
}));
|
||||
}
|
||||
@ -277,7 +277,7 @@ function filterOutlineLoops(
|
||||
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
|
||||
}
|
||||
|
||||
function floodFillComponent(
|
||||
export function floodFillComponent(
|
||||
binary: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
|
||||
@ -137,6 +137,13 @@ export const DEFAULT_MASK_CONFIG: Required<
|
||||
splitWallsColorDistSq: 1400,
|
||||
splitWallsChromaBlurRadius: 5,
|
||||
splitWallsNeutralChromaMax: 14,
|
||||
splitWallsEdgeBarrierThreshold: 160,
|
||||
splitWallsCloseMaskRadius:4,
|
||||
manualSplitWalls: false,
|
||||
manualSplitWallsMaxCount: 8,
|
||||
manualSplitWallsGapAbsorbDilatePx: 5,
|
||||
magneticLasso: false,
|
||||
activeContourRefine: false,
|
||||
};
|
||||
|
||||
export type ResolvedMaskSegmentRuntime = {
|
||||
@ -209,6 +216,24 @@ export function mergeMaskConfig(
|
||||
splitWallsNeutralChromaMax:
|
||||
partial.splitWallsNeutralChromaMax ??
|
||||
DEFAULT_MASK_CONFIG.splitWallsNeutralChromaMax,
|
||||
splitWallsEdgeBarrierThreshold:
|
||||
partial.splitWallsEdgeBarrierThreshold ??
|
||||
DEFAULT_MASK_CONFIG.splitWallsEdgeBarrierThreshold,
|
||||
splitWallsCloseMaskRadius:
|
||||
partial.splitWallsCloseMaskRadius ??
|
||||
DEFAULT_MASK_CONFIG.splitWallsCloseMaskRadius,
|
||||
manualSplitWalls:
|
||||
partial.manualSplitWalls ?? DEFAULT_MASK_CONFIG.manualSplitWalls,
|
||||
manualSplitWallsMaxCount:
|
||||
partial.manualSplitWallsMaxCount ??
|
||||
DEFAULT_MASK_CONFIG.manualSplitWallsMaxCount,
|
||||
manualSplitWallsGapAbsorbDilatePx:
|
||||
partial.manualSplitWallsGapAbsorbDilatePx ??
|
||||
DEFAULT_MASK_CONFIG.manualSplitWallsGapAbsorbDilatePx,
|
||||
magneticLasso:
|
||||
partial.magneticLasso ?? DEFAULT_MASK_CONFIG.magneticLasso,
|
||||
activeContourRefine:
|
||||
partial.activeContourRefine ?? DEFAULT_MASK_CONFIG.activeContourRefine,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -414,6 +414,10 @@ export type RegionMaskData = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
wallSubLabels?: Uint8Array;
|
||||
/** Semantic index → name table captured at segmentation time (must match labels buffer). */
|
||||
indexToName?: string[];
|
||||
/** Wall semantic index in labels buffer (captured at segmentation time). */
|
||||
wallSemanticIdx?: number;
|
||||
};
|
||||
|
||||
/** downsample mask path building (screen display does not need segmentation resolution, click still uses full resolution pickMap) */
|
||||
@ -421,7 +425,8 @@ export function downsampleMaskDataForPaths(
|
||||
maskData: RegionMaskData,
|
||||
maxLongSide: number,
|
||||
): RegionMaskData {
|
||||
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
|
||||
const { labels, baseboardBinary, cols, rows, wallSubLabels, indexToName, wallSemanticIdx } =
|
||||
maskData;
|
||||
const longSide = Math.max(cols, rows);
|
||||
if (longSide <= maxLongSide) {
|
||||
return maskData;
|
||||
@ -460,6 +465,8 @@ export function downsampleMaskDataForPaths(
|
||||
cols: dstCols,
|
||||
rows: dstRows,
|
||||
wallSubLabels: outWallSub,
|
||||
indexToName,
|
||||
wallSemanticIdx,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -16,15 +16,88 @@ function maskCfg() {
|
||||
return getMaskSegmentRuntimeConfig().mask;
|
||||
}
|
||||
|
||||
function bboxToPolygon(bbox: SegmentRegion['bbox']): { x: number; y: number }[] {
|
||||
return [
|
||||
{ x: bbox.x, y: bbox.y },
|
||||
{ x: bbox.x + bbox.w, y: bbox.y },
|
||||
{ x: bbox.x + bbox.w, y: bbox.y + bbox.h },
|
||||
{ x: bbox.x, y: bbox.y + bbox.h },
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
/** Moore-neighbor boundary tracer on a binary mask component. */
|
||||
function traceMaskPolygon(
|
||||
mask: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Point[] {
|
||||
// Find first non-zero pixel (top-left)
|
||||
let startX = -1, startY = -1;
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (mask[y * cols + x]) { startX = x; startY = y; break; }
|
||||
}
|
||||
if (startX >= 0) break;
|
||||
}
|
||||
if (startX < 0) return [];
|
||||
|
||||
// Moore 8-neighbor clockwise trace
|
||||
const dirs: [number, number][] = [
|
||||
[1, 0], [1, -1], [0, -1], [-1, -1],
|
||||
[-1, 0], [-1, 1], [0, 1], [1, 1],
|
||||
];
|
||||
const path: Point[] = [];
|
||||
let cx = startX, cy = startY;
|
||||
let dir = 7; // start searching from up-left
|
||||
|
||||
for (let i = 0; i < cols * rows; i++) {
|
||||
path.push({ x: cx, y: cy });
|
||||
let found = false;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const d = (dir + 1 + j) % 8; // search clockwise from last direction+1
|
||||
const nx = cx + dirs[d][0];
|
||||
const ny = cy + dirs[d][1];
|
||||
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
|
||||
if (mask[ny * cols + nx]) {
|
||||
cx = nx; cy = ny; dir = (d + 4) % 8; // face back toward previous pixel
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) break;
|
||||
if (path.length > 2 && cx === startX && cy === startY) break;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Douglas-Peucker polygon simplification (epsilon in pixels). */
|
||||
function simplifyPolygon(points: Point[], epsilon: number): Point[] {
|
||||
if (points.length <= 2) return [...points];
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = 1;
|
||||
keep[points.length - 1] = 1;
|
||||
|
||||
const recurse = (s: number, e: number) => {
|
||||
if (e - s <= 1) return;
|
||||
const dx = points[e].x - points[s].x;
|
||||
const dy = points[e].y - points[s].y;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
let maxDist = 0, maxIdx = s;
|
||||
for (let i = s + 1; i < e; i++) {
|
||||
let d: number;
|
||||
if (lenSq === 0) {
|
||||
d = Math.hypot(points[i].x - points[s].x, points[i].y - points[s].y);
|
||||
} else {
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((points[i].x - points[s].x) * dx + (points[i].y - points[s].y) * dy) / lenSq,
|
||||
));
|
||||
d = Math.hypot(points[i].x - (points[s].x + t * dx), points[i].y - (points[s].y + t * dy));
|
||||
}
|
||||
if (d > maxDist) { maxDist = d; maxIdx = i; }
|
||||
}
|
||||
if (maxDist > epsilon) { keep[maxIdx] = 1; recurse(s, maxIdx); recurse(maxIdx, e); }
|
||||
};
|
||||
recurse(0, points.length - 1);
|
||||
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
const POLYGON_SIMPLIFY_EPSILON = 2.5;
|
||||
|
||||
function computeLabChromaMaps(
|
||||
originBgr: Uint8Array,
|
||||
cols: number,
|
||||
@ -42,6 +115,79 @@ function computeLabChromaMaps(
|
||||
return { aMap, bMap };
|
||||
}
|
||||
|
||||
/** Per-channel BGR Sobel gradient magnitude. max(B, G, R) for sensitivity to color edges. */
|
||||
function buildEdgeBarrierMask(
|
||||
bgr: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
wallIdx: number,
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
threshold: number,
|
||||
): Uint8Array {
|
||||
const n = cols * rows;
|
||||
const barriers = new Uint8Array(n);
|
||||
if (threshold <= 0) return barriers;
|
||||
|
||||
// Per-channel Sobel 3x3 → take max raw gradient.
|
||||
// Raw range is [0, ~1442] for 8‑bit BGR. No normalization — a single
|
||||
// extremely strong edge (e.g. window frame) would compress all other
|
||||
// edges if we normalized relative to maxG.
|
||||
const C = cols;
|
||||
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
const r0 = (y - 1) * C;
|
||||
const r1 = y * C;
|
||||
const r2 = (y + 1) * C;
|
||||
for (let x = 1; x < C - 1; x++) {
|
||||
const i = r1 + x;
|
||||
if (labels[i] !== wallIdx || baseboardBinary[i]) continue;
|
||||
|
||||
const a0 = r0 + (x - 1), a1 = r0 + x, a2 = r0 + (x + 1);
|
||||
const b0 = r1 + (x - 1), b2 = r1 + (x + 1);
|
||||
const c0 = r2 + (x - 1), c1 = r2 + x, c2 = r2 + (x + 1);
|
||||
|
||||
let best = 0;
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
const a = bgr[a0 * 3 + ch];
|
||||
const b = bgr[a1 * 3 + ch];
|
||||
const c = bgr[a2 * 3 + ch];
|
||||
const d = bgr[b0 * 3 + ch];
|
||||
const e = bgr[b2 * 3 + ch];
|
||||
const f = bgr[c0 * 3 + ch];
|
||||
const gv = bgr[c1 * 3 + ch];
|
||||
const h = bgr[c2 * 3 + ch];
|
||||
const gx = -a + c - 2 * d + 2 * e - f + h;
|
||||
const gy = -a - 2 * b - c + f + 2 * gv + h;
|
||||
const mag = Math.sqrt(gx * gx + gy * gy);
|
||||
if (mag > best) best = mag;
|
||||
}
|
||||
if (best > threshold) barriers[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dilate 1px to widen the barrier slightly
|
||||
return dilateBinary1px(barriers, cols, rows);
|
||||
}
|
||||
|
||||
function dilateBinary1px(src: Uint8Array, cols: number, rows: number): Uint8Array {
|
||||
const dst = new Uint8Array(src);
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
const rc = y * cols;
|
||||
for (let x = 1; x < cols - 1; x++) {
|
||||
const i = rc + x;
|
||||
if (src[i]) continue;
|
||||
if (
|
||||
src[i - 1] || src[i + 1] ||
|
||||
src[(y - 1) * cols + x] || src[(y + 1) * cols + x]
|
||||
) {
|
||||
dst[i] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
function chromaMag(a: number, b: number): number {
|
||||
const da = a - 128;
|
||||
const db = b - 128;
|
||||
@ -109,6 +255,81 @@ function isWallPixel(
|
||||
return labels[i] === wallIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Morphological close on the wall mask: dilate then erode to fill small
|
||||
* non-wall holes (windows, doors, occlusions) that would otherwise
|
||||
* fragment a single wall into disconnected components during BFS.
|
||||
* Returns a temporary labels array with holes filled as wallIdx.
|
||||
*/
|
||||
function closeWallMask(
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
wallIdx: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
radius: number,
|
||||
): { labels: Uint8Array; baseboardBinary: Uint8Array } {
|
||||
const n = cols * rows;
|
||||
|
||||
// Binary wall mask
|
||||
const bin = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] === wallIdx && !baseboardBinary[i]) bin[i] = 1;
|
||||
}
|
||||
|
||||
// Dilate N times
|
||||
let dilated = bin;
|
||||
for (let pass = 0; pass < radius; pass++) {
|
||||
dilated = dilateBinary1px(dilated, cols, rows);
|
||||
}
|
||||
|
||||
// Erode N times
|
||||
let closed = dilated;
|
||||
for (let pass = 0; pass < radius; pass++) {
|
||||
closed = erodeBinary1px(closed, cols, rows);
|
||||
}
|
||||
|
||||
// Build closed labels: pixels that were NOT wall but are now in the closed
|
||||
// mask get wallIdx so the BFS can cross them. Original non-wall pixels
|
||||
// outside the wall area are unchanged.
|
||||
const closedLabels = new Uint8Array(labels);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (closed[i] && labels[i] !== wallIdx && !baseboardBinary[i]) {
|
||||
closedLabels[i] = wallIdx;
|
||||
}
|
||||
}
|
||||
|
||||
// These pixels were baseboard or other semantic — keep them excluded
|
||||
const closedBaseboard = new Uint8Array(baseboardBinary);
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (closed[i] && labels[i] !== wallIdx && baseboardBinary[i]) {
|
||||
// Baseboard inside the closed area: treat as wall so it doesn't block BFS
|
||||
closedLabels[i] = wallIdx;
|
||||
closedBaseboard[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { labels: closedLabels, baseboardBinary: closedBaseboard };
|
||||
}
|
||||
|
||||
function erodeBinary1px(src: Uint8Array, cols: number, rows: number): Uint8Array {
|
||||
const dst = new Uint8Array(src);
|
||||
for (let y = 1; y < rows - 1; y++) {
|
||||
const rc = y * cols;
|
||||
for (let x = 1; x < cols - 1; x++) {
|
||||
const i = rc + x;
|
||||
if (!src[i]) continue;
|
||||
if (
|
||||
!src[rc + (x - 1)] || !src[rc + (x + 1)] ||
|
||||
!src[(y - 1) * cols + x] || !src[(y + 1) * cols + x]
|
||||
) {
|
||||
dst[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-connected component growth: compares against component chroma mean to avoid chain bridging;
|
||||
* forces separation at neutral/colored wall boundaries.
|
||||
@ -119,6 +340,7 @@ function labelWallComponents(
|
||||
wallIdx: number,
|
||||
aMap: Uint8Array,
|
||||
bMap: Uint8Array,
|
||||
barrierMask: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
distSqThreshold: number,
|
||||
@ -164,6 +386,7 @@ function labelWallComponents(
|
||||
const nx = ni % cols;
|
||||
if (Math.abs(nx - cx) > 1) continue;
|
||||
if (!isWallPixel(labels, baseboardBinary, wallIdx, ni)) continue;
|
||||
if (barrierMask[ni]) continue;
|
||||
if (compLabels[ni] >= 0) continue;
|
||||
|
||||
const na = aMap[ni];
|
||||
@ -379,6 +602,25 @@ function mergeSmallComponents(
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: any component that is smaller than its most-adjacent
|
||||
// neighbor is almost certainly a barrier artefact — merge it.
|
||||
for (let c = 0; c < compCount; c++) {
|
||||
if (stats[c].area <= 0) continue;
|
||||
const neighbors = adjacency.get(c);
|
||||
if (!neighbors || neighbors.size === 0) continue;
|
||||
let bestNeighbor = -1;
|
||||
let bestBorder = 0;
|
||||
for (const [nb, border] of neighbors) {
|
||||
if (border > bestBorder) {
|
||||
bestBorder = border;
|
||||
bestNeighbor = nb;
|
||||
}
|
||||
}
|
||||
if (bestNeighbor < 0) continue;
|
||||
if (stats[c].area >= stats[bestNeighbor].area) continue;
|
||||
union(c, bestNeighbor);
|
||||
}
|
||||
|
||||
const pixelCount = cols * rows;
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const c = compLabels[i];
|
||||
@ -387,6 +629,78 @@ function mergeSmallComponents(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh-adjacency pass: rebuild the adjacency graph from current labels and
|
||||
* merge every component below minArea into its most-adjacent larger neighbor.
|
||||
* Runs until all tiny fragments are absorbed or no more merges possible.
|
||||
*/
|
||||
function mergeFreshTinyComponents(
|
||||
compLabels: Int32Array,
|
||||
stats: WallComponent[],
|
||||
cols: number,
|
||||
rows: number,
|
||||
minArea: number,
|
||||
): void {
|
||||
const compCount = stats.length;
|
||||
// Build adjacency from current labels
|
||||
const adjacency = new Map<number, Map<number, number>>();
|
||||
const addEdge = (a: number, b: number) => {
|
||||
if (a === b) return;
|
||||
let m = adjacency.get(a);
|
||||
if (!m) { m = new Map(); adjacency.set(a, m); }
|
||||
m.set(b, (m.get(b) ?? 0) + 1);
|
||||
};
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = y * cols + x;
|
||||
const a = compLabels[i];
|
||||
if (a < 0) continue;
|
||||
if (x + 1 < cols) { const b = compLabels[i + 1]; if (b >= 0) addEdge(a, b); }
|
||||
if (y + 1 < rows) { const b = compLabels[i + cols]; if (b >= 0) addEdge(a, b); }
|
||||
}
|
||||
}
|
||||
|
||||
const remap = new Int32Array(compCount);
|
||||
for (let i = 0; i < compCount; i++) remap[i] = i;
|
||||
const find = (x: number): number => {
|
||||
while (remap[x] !== x) { remap[x] = remap[remap[x]]; x = remap[x]; }
|
||||
return x;
|
||||
};
|
||||
const union = (a: number, b: number) => {
|
||||
const ra = find(a), rb = find(b);
|
||||
if (ra === rb) return;
|
||||
if (stats[ra].area >= stats[rb].area) {
|
||||
remap[rb] = ra; stats[ra].area += stats[rb].area; stats[rb].area = 0;
|
||||
} else {
|
||||
remap[ra] = rb; stats[rb].area += stats[ra].area; stats[ra].area = 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (let iter = 0; iter < compCount; iter++) {
|
||||
let changed = false;
|
||||
for (let c = 0; c < compCount; c++) {
|
||||
if (stats[c].area <= 0 || stats[c].area >= minArea) continue;
|
||||
const nbrs = adjacency.get(c);
|
||||
if (!nbrs || nbrs.size === 0) continue;
|
||||
let bestNb = -1, bestBorder = 0;
|
||||
for (const [nb, border] of nbrs) {
|
||||
if (remap[nb] !== nb) continue;
|
||||
if (border > bestBorder) { bestBorder = border; bestNb = nb; }
|
||||
}
|
||||
if (bestNb < 0) continue;
|
||||
union(c, bestNb);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
const n = cols * rows;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = compLabels[i];
|
||||
if (c >= 0) compLabels[i] = find(c);
|
||||
}
|
||||
}
|
||||
|
||||
function relabelComponentsContiguous(
|
||||
compLabels: Int32Array,
|
||||
cols: number,
|
||||
@ -413,7 +727,7 @@ function relabelComponentsContiguous(
|
||||
return { labels: out, compCount, stats };
|
||||
}
|
||||
|
||||
function buildPickMapAfterWallSplit(
|
||||
export function buildPickMapAfterWallSplit(
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
wallIdx: number,
|
||||
@ -437,12 +751,15 @@ function buildPickMapAfterWallSplit(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (labels[i] === wallIdx && wallSubLabels[i] !== WALL_SUB_LABEL_NONE) {
|
||||
const wallName = `wall-${wallSubLabels[i] + 1}`;
|
||||
const regionId = nameToId.get(wallName);
|
||||
if (regionId !== undefined) {
|
||||
pick[i] = regionId + 1;
|
||||
if (wallIdx >= 0 && labels[i] === wallIdx) {
|
||||
if (wallSubLabels[i] !== WALL_SUB_LABEL_NONE) {
|
||||
const wallName = `wall-${wallSubLabels[i] + 1}`;
|
||||
const regionId = nameToId.get(wallName);
|
||||
if (regionId !== undefined) {
|
||||
pick[i] = regionId + 1;
|
||||
}
|
||||
}
|
||||
// Unpartitioned wall pixels stay 0 (no parent "wall" region after manual split).
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -459,7 +776,50 @@ function buildPickMapAfterWallSplit(
|
||||
return pick;
|
||||
}
|
||||
|
||||
function dilatePickBuffer1px(
|
||||
/**
|
||||
* Manual lasso split: copy the existing pick map and rewrite wall pixels only.
|
||||
* Non-wall pick codes stay identical so prior paints and hit-testing remain stable.
|
||||
*/
|
||||
export function patchPickMapForManualWallSplit(
|
||||
existingPick: Uint8Array,
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
wallIdx: number,
|
||||
wallSubLabels: Uint8Array,
|
||||
nameToId: Map<string, number>,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): Uint8Array {
|
||||
const pixelCount = cols * rows;
|
||||
const pick = new Uint8Array(existingPick);
|
||||
|
||||
if (wallIdx < 0) {
|
||||
return pick;
|
||||
}
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
if (baseboardBinary[i]) {
|
||||
continue;
|
||||
}
|
||||
if (labels[i] !== wallIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sub = wallSubLabels[i];
|
||||
if (sub === WALL_SUB_LABEL_NONE) {
|
||||
pick[i] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wallName = `wall-${sub + 1}`;
|
||||
const regionId = nameToId.get(wallName);
|
||||
pick[i] = regionId !== undefined ? regionId + 1 : 0;
|
||||
}
|
||||
|
||||
return pick;
|
||||
}
|
||||
|
||||
export function dilatePickBuffer1px(
|
||||
pick: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
@ -505,6 +865,130 @@ function dilatePickBuffer1px(
|
||||
return dst;
|
||||
}
|
||||
|
||||
const GAP_ABSORB_NEIGHBOURS: [number, number][] = [
|
||||
[-1, -1], [0, -1], [1, -1],
|
||||
[-1, 0], /* */ [1, 0],
|
||||
[-1, 1], [0, 1], [1, 1],
|
||||
];
|
||||
|
||||
export type LassoPolyBBox = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function expandLassoPolyBBox(b: LassoPolyBBox, x: number, y: number): void {
|
||||
if (b.w === 0 && b.h === 0) {
|
||||
b.x = x;
|
||||
b.y = y;
|
||||
b.w = 1;
|
||||
b.h = 1;
|
||||
return;
|
||||
}
|
||||
const right = b.x + b.w;
|
||||
const bottom = b.y + b.h;
|
||||
if (x < b.x) {
|
||||
b.w = right - x;
|
||||
b.x = x;
|
||||
} else if (x + 1 > right) {
|
||||
b.w = x + 1 - b.x;
|
||||
}
|
||||
if (y < b.y) {
|
||||
b.h = bottom - y;
|
||||
b.y = y;
|
||||
} else if (y + 1 > bottom) {
|
||||
b.h = y + 1 - b.y;
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeLassoPolyStats(
|
||||
polyLabels: Uint8Array,
|
||||
polyCount: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
areas: number[],
|
||||
bboxes: LassoPolyBBox[],
|
||||
): void {
|
||||
areas.fill(0);
|
||||
for (let pi = 0; pi < polyCount; pi++) {
|
||||
bboxes[pi] = { x: cols, y: rows, w: 0, h: 0 };
|
||||
}
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = y * cols + x;
|
||||
const pi = polyLabels[i];
|
||||
if (pi === WALL_SUB_LABEL_NONE || pi >= polyCount) {
|
||||
continue;
|
||||
}
|
||||
areas[pi]++;
|
||||
expandLassoPolyBBox(bboxes[pi], x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Morphologically dilate each lasso polygon into adjacent unassigned wall pixels
|
||||
* (up to `dilateRadius` seg pixels) so thin gaps against the wall mask merge in.
|
||||
*/
|
||||
export function absorbSmallWallGapsForLassoPolygons(
|
||||
polyLabels: Uint8Array,
|
||||
polyCount: number,
|
||||
areas: number[],
|
||||
bboxes: LassoPolyBBox[],
|
||||
labels: Uint8Array,
|
||||
baseboardBinary: Uint8Array,
|
||||
wallSemanticIdx: number,
|
||||
priorAssignedLabels: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
dilateRadius: number,
|
||||
): void {
|
||||
if (
|
||||
polyCount <= 0 ||
|
||||
dilateRadius <= 0 ||
|
||||
wallSemanticIdx < 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpandable = (i: number): boolean => {
|
||||
if (labels[i] !== wallSemanticIdx) return false;
|
||||
if (baseboardBinary[i]) return false;
|
||||
if (priorAssignedLabels[i] !== WALL_SUB_LABEL_NONE) return false;
|
||||
return polyLabels[i] === WALL_SUB_LABEL_NONE;
|
||||
};
|
||||
|
||||
for (let polyIdx = 0; polyIdx < polyCount; polyIdx++) {
|
||||
for (let pass = 0; pass < dilateRadius; pass++) {
|
||||
const toAdd: number[] = [];
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const i = y * cols + x;
|
||||
if (polyLabels[i] !== polyIdx) continue;
|
||||
|
||||
for (const [dx, dy] of GAP_ABSORB_NEIGHBOURS) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
|
||||
const ni = ny * cols + nx;
|
||||
if (isExpandable(ni)) {
|
||||
toAdd.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const ni of toAdd) {
|
||||
polyLabels[ni] = polyIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recomputeLassoPolyStats(polyLabels, polyCount, cols, rows, areas, bboxes);
|
||||
}
|
||||
|
||||
/**
|
||||
* After semantic segmentation, subdivide the wall region into wall-1, wall-2… by source image texture features
|
||||
*/
|
||||
@ -536,7 +1020,20 @@ export function splitWallRegionsByTexture(
|
||||
return result;
|
||||
}
|
||||
|
||||
// Close small mask holes so non-wall pixels (windows, doors) don't
|
||||
// fragment a single wall into disconnected BFS components.
|
||||
const closeRadius = cfg.splitWallsCloseMaskRadius ?? 3;
|
||||
const closed = closeRadius > 0
|
||||
? closeWallMask(labels, baseboardBinary, wallIdx, cols, rows, closeRadius)
|
||||
: { labels, baseboardBinary };
|
||||
const bfsLabels = closed.labels;
|
||||
const bfsBaseboard = closed.baseboardBinary;
|
||||
|
||||
const { aMap: rawA, bMap: rawB } = computeLabChromaMaps(originBgr, cols, rows);
|
||||
const barrierMask = buildEdgeBarrierMask(
|
||||
originBgr, cols, rows, wallIdx, bfsLabels, bfsBaseboard,
|
||||
cfg.splitWallsEdgeBarrierThreshold ?? 36,
|
||||
);
|
||||
const distSqThreshold = cfg.splitWallsColorDistSq;
|
||||
const neutralChromaMax = cfg.splitWallsNeutralChromaMax;
|
||||
const minAreaFloor = Math.max(
|
||||
@ -545,11 +1042,12 @@ export function splitWallRegionsByTexture(
|
||||
);
|
||||
|
||||
const { compLabels: rawCompLabels, compCount: rawCount } = labelWallComponents(
|
||||
labels,
|
||||
baseboardBinary,
|
||||
bfsLabels,
|
||||
bfsBaseboard,
|
||||
wallIdx,
|
||||
rawA,
|
||||
rawB,
|
||||
barrierMask,
|
||||
cols,
|
||||
rows,
|
||||
distSqThreshold,
|
||||
@ -573,8 +1071,18 @@ export function splitWallRegionsByTexture(
|
||||
neutralChromaMax,
|
||||
);
|
||||
|
||||
const { labels: finalCompLabels, compCount, stats: finalStats } =
|
||||
relabelComponentsContiguous(rawCompLabels, cols, rows);
|
||||
let finalCompLabels: Int32Array;
|
||||
let compCount: number;
|
||||
let finalStats: WallComponent[];
|
||||
|
||||
{
|
||||
const relabeled = relabelComponentsContiguous(rawCompLabels, cols, rows);
|
||||
mergeFreshTinyComponents(relabeled.labels, relabeled.stats, cols, rows, minAreaFloor);
|
||||
const final = relabelComponentsContiguous(relabeled.labels, cols, rows);
|
||||
finalCompLabels = final.labels;
|
||||
compCount = final.compCount;
|
||||
finalStats = final.stats;
|
||||
}
|
||||
|
||||
if (compCount === 0) {
|
||||
return result;
|
||||
@ -607,18 +1115,37 @@ export function splitWallRegionsByTexture(
|
||||
const wallHex = wallRef?.hex ?? wallRegion.hex;
|
||||
const wallColor = wallRef?.bgr ?? wallRegion.color;
|
||||
|
||||
// Build per-component binary masks and trace simplified polygons
|
||||
const compMasks = new Array<Uint8Array>(ranked.length);
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const c = finalCompLabels[i];
|
||||
if (c < 0) continue;
|
||||
const rank = rankMap.get(c);
|
||||
if (rank === undefined) continue;
|
||||
if (!compMasks[rank]) compMasks[rank] = new Uint8Array(pixelCount);
|
||||
compMasks[rank][i] = 1;
|
||||
}
|
||||
|
||||
const nonWallRegions = regions.filter(reg => reg.name !== 'wall');
|
||||
const wallSubRegions: SegmentRegion[] = ranked.map((s, rank) => {
|
||||
const bbox = s.bbox;
|
||||
const poly = bboxToPolygon(bbox);
|
||||
const mask = compMasks[rank];
|
||||
const rawPoly = mask ? traceMaskPolygon(mask, cols, rows) : [];
|
||||
const poly = simplifyPolygon(rawPoly, POLYGON_SIMPLIFY_EPSILON);
|
||||
// Fallback to bbox if contour tracing failed
|
||||
const fallback = poly.length >= 3 ? poly : [
|
||||
{ x: s.bbox.x, y: s.bbox.y },
|
||||
{ x: s.bbox.x + s.bbox.w, y: s.bbox.y },
|
||||
{ x: s.bbox.x + s.bbox.w, y: s.bbox.y + s.bbox.h },
|
||||
{ x: s.bbox.x, y: s.bbox.y + s.bbox.h },
|
||||
];
|
||||
return {
|
||||
id: 0,
|
||||
name: `wall-${rank + 1}`,
|
||||
hex: wallHex,
|
||||
color: { ...wallColor },
|
||||
polygons: [poly],
|
||||
outlinePolygons: [poly],
|
||||
bbox,
|
||||
polygons: [fallback],
|
||||
outlinePolygons: [fallback],
|
||||
bbox: s.bbox,
|
||||
area: s.area,
|
||||
};
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user