feat: add manual lasso wall splitting with magnetic edge-snapping and active contour refinement
Some checks failed
Deploy Docs to GitHub Pages / deploy (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled

- 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:
a1518 2026-07-06 20:23:57 -07:00
parent 26d3717169
commit 3a3f07628d
41 changed files with 4014 additions and 1002 deletions

990
README.md

File diff suppressed because it is too large Load Diff

View File

@ -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);
});

File diff suppressed because one or more lines are too long

View File

@ -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 8bit Sobel magnitude on each B/G/R
* channel (max per pixel, range 01442). Visible wall seams 120280,
* subtle lighting gradients 2080. 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;

View File

@ -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
View File

@ -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
View File

@ -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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

View File

@ -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;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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;

File diff suppressed because one or more lines are too long

View File

@ -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
*/

File diff suppressed because one or more lines are too long

View File

@ -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` |

View File

@ -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 ≈ 120280, subtle lighting gradients ≈ 2080 |
| `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.

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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` |

View File

@ -29,5 +29,23 @@ title: "PropsmaskConfig"
| `splitWallsColorDistSq` | `number` | `1400` | 连通分量色度均值距离平方阈值 |
| `splitWallsChromaBlurRadius` | `number` | `5` | 保留:色度平滑半径 |
| `splitWallsNeutralChromaMax` | `number` | `14` | 白/灰墙面低色度半径;与彩色墙面的强制边界 |
| `splitWallsEdgeBarrierThreshold` | `number` | `160` | 逐通道 BGR Sobel 梯度边缘屏障阈值0 = 禁用)。可见墙面接缝 ≈ 120280细微光照渐变 ≈ 2080 |
| `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 跨越强边缘(窗框、门框)。

View File

@ -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'`

View File

@ -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 简化去除冗余顶点
- 结果:多边形贴合真实墙面轮廓,而非原始点击位置

View File

@ -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 状态转换,宿主应用可据此显示相应的加载状态。

View File

@ -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 的完整示例

View File

@ -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',

View File

@ -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

View File

@ -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 8bit Sobel magnitude on each B/G/R
* channel (max per pixel, range 01442). Visible wall seams 120280,
* subtle lighting gradients 2080. 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 = {

View File

@ -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
View 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
View 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))),
}));
}

View File

@ -277,7 +277,7 @@ function filterOutlineLoops(
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(
export function floodFillComponent(
binary: Uint8Array,
cols: number,
rows: number,

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -16,14 +16,87 @@ 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,
@ -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 8bit 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) {
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,
};
});