Compare commits

..

4 Commits

Author SHA1 Message Date
a1518
3a3f07628d 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>
2026-07-06 20:23:57 -07:00
a1518
26d3717169 Fix GitHub Pages deploy workflow permissions and branch trigger
- Change trigger branch from main to 0.4.0
- Upgrade contents permission from read to write for gh-pages push
- Also trigger on workflow file changes

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:34:48 -07:00
a1518
bea6de3767 Add Docusaurus documentation site with i18n support
- Docusaurus 3.7.0 with both English and Chinese (zh-CN) locales
- Full API reference split into structured pages
- Homepage with hero, feature cards, and quick start section
- GitHub Actions workflow for auto-deployment to GitHub Pages

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 23:25:56 -07:00
a1518
f7fd70005d docs: add bilingual README (EN default, ZH-CN via switcher)
Split README into README.md (English) and README.zh-CN.md (Chinese)
with cross-links at the top of each file. All heading anchors in the
Chinese version use explicit <a id="..."> tags for reliable TOC
navigation on GitHub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:50:32 -07:00
91 changed files with 26059 additions and 997 deletions

42
.github/workflows/deploy-docs.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Deploy Docs to GitHub Pages
on:
push:
branches: [0.4.0, main]
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
permissions:
contents: write
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- run: npm ci
- run: npm run build
- uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/build
publish_branch: gh-pages

992
README.md

File diff suppressed because it is too large Load Diff

1074
README.zh-CN.md Normal file

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

5
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Docusaurus docs site
node_modules/
.docusaurus/
build/
.cache/

View File

@ -0,0 +1,72 @@
---
id: callbacks
title: "Props: Callbacks"
---
# 📞 Props: Callbacks
| Prop | Signature | Description |
| --- | --- | --- |
| `onWatch` | `(state, durationMs, detail?) => void` | Initialization stage callback; `durationMs` is relative to this `init` start |
| `onPaintCallback` | `(payload: PaintCallbackPayload) => void` | Fires on successful paint, or when tapping a region without a brush selected |
| `onError` | `(message, error?) => void` | Segmentation or loading failure |
## PaintCallbackPayload
(Discriminated union, distinguished by `payload.kind`):
```ts
// Successful paint
{
kind: 'painted';
regionId: number;
regionName: string;
color: BgrColor;
configJson?: Record<string, unknown>; // from setPaintColor / initialPaintConfigJson
}
// Tapped a valid region without selecting a brush (no paint performed)
{
kind: 'brush_required';
hint: string; // e.g. "Please select a brush color first (bottom color bar or ref.setPaintColor)"
regionId: number;
regionName: string;
}
```
Example:
```tsx
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
showToast(payload.hint);
return;
}
savePaintRecord(payload.regionId, payload.color, payload.configJson);
}}
```
## onWatch detail (MaskSegmentWatchDetail)
| Field | Type | Description |
| --- | --- | --- |
| `regionCount` | `number` | Current effective region count |
| `maskPathsReady` | `boolean` | Whether outline Skia paths are ready |
| `freqLayersReady` | `boolean` | Whether frequency Shader textures are ready |
| `errorMessage` | `string` | Failure description in `error` state |
### onWatch State Flow
```
init
→ images_loaded Origin + mask read complete
→ mask_aligned Mask dimensions aligned
→ mask_sampled Mask pixel sampling complete
→ regions_ready Region extraction succeeded
→ layers_ready Paint texture layers ready (detail.maskPathsReady may still be false)
→ interactive Interactive (can tap regions, select colors, paint)
→ mask_paths_ready Outline paths ready (carousel dashed outlines can display; detail.maskPathsReady is true)
→ error Failure (detail.errorMessage has description)
```
`layers_ready` / `interactive` may fire before outline paths finish computing. If the host dismisses a blocking loader at `interactive`, the user can already operate; carousel dashed outlines appear automatically after `mask_paths_ready`.

65
docs/docs/api/index.md Normal file
View File

@ -0,0 +1,65 @@
---
id: index
title: API Reference
---
# 📖 API Reference
## Imports
```tsx
import MaskSegmentCanvas, {
type MaskSegmentCanvasRef,
type MaskSegmentCanvasProps,
type MaskSegmentSession,
type MaskSegmentWatchState,
type MaskSegmentWatchDetail,
type BgrColor,
type MaskSemanticColor,
type PaintCallbackPayload,
type PaintedRegionRecord,
type PipelineConfig,
type MaskSegmentConfig,
type PaintConfig,
type InteractionConfig,
type SavePaintResult,
type LassoPolygon,
type ManualWallPartition,
MASK_SEMANTIC_COLORS,
BASEBOARD_SEMANTIC_NAME,
prewarmPngBgrCacheAsync,
DEFAULT_PIPELINE_CONFIG,
DEFAULT_MASK_CONFIG,
DEFAULT_PAINT_CONFIG,
DEFAULT_INTERACTION_CONFIG,
} from 'react-native-mask-segment-canvas';
```
| Category | Names |
| --- | --- |
| 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` |
| Utilities | `prewarmPngBgrCacheAsync` |
| Runtime defaults | `DEFAULT_*_CONFIG` |
---
## Props Overview
| Category | Description |
| --- | --- |
| [Image & Initialization](/docs/api/props-image) | `originUrl`, `maskUrl`, `initialSession`, `initialPaintColor` |
| [Semantic Colors & Outline](/docs/api/props-semantic) | `semanticColors`, `regionOutlineColor` |
| [maskConfig](/docs/api/mask-config) | Segmentation and semantic region configuration |
| [pipelineConfig](/docs/api/pipeline-config) | Resolution and processing pipeline configuration |
| [paintConfig](/docs/api/paint-config) | Paint rendering and texture blending configuration |
| [interactionConfig](/docs/api/interaction-config) | Touch interaction and hit testing configuration |
| [UI Controls & Styling](/docs/api/ui-controls) | Visibility toggles, custom renderers, styling |
| [Callbacks](/docs/api/callbacks) | `onWatch`, `onPaintCallback`, `onError` |
| [Ref Methods](/docs/api/ref-methods) | Imperative methods via `ref` |
| [Storage Convention](/docs/api/storage) | Session persistence and PNG export |

View File

@ -0,0 +1,15 @@
---
id: interaction-config
title: "Props: interactionConfig"
---
# 👆 Props: interactionConfig
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `pickMapSearchRadiusPx` | `number` | `14` | Click pickMap search radius (pixels) |
| `kickMaskPickRadiusPx` | `number` | `36` | Baseboard mask pick radius |
| `thinStripPadding` | `number` | `0.008` | Thin strip (baseboard) tap expansion ratio |
| `regionPadding` | `number` | `0.003` | Normal region tap expansion ratio |
| `initRegionFlashMs` | `number` | `1000` | Duration each dashed outline stays during initial carousel (ms) |
| `enableInitRegionFlash` | `boolean` | `true` | Enable initial carousel animation |

View File

@ -0,0 +1,51 @@
---
id: mask-config
title: "Props: maskConfig"
---
# 🧩 Props: maskConfig
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `semanticColors` | `MaskSemanticColor[]` | built-in palette | Mask semantic colors (overridable by top-level `semanticColors`) |
| `blackThreshold` | `number` | `30` | Pixels with max(B,G,R) below this value are treated as black background |
| `maxRegionColors` | `number` | `6` | Maximum semantic regions retained |
| `quantStep` | `number` | `64` | Baseboard quantization step |
| `baseboardMaxColorDist` | `number` | `42` | Baseboard color distance threshold |
| `baseboardStripQuantKeys` | `string[]` | built-in keys | Baseboard strip quantization keys, format `"b,g,r"` |
| `wallQuantKeys` | `string[]` | built-in keys | Wall quantization keys |
| `cabinetQuantKeys` | `string[]` | built-in keys | Cabinet quantization keys |
| `secondarySemanticNames` | `string[]` | `garageDoor, roof, eave` | Secondary semantic names |
| `secondaryMinPixelRatio` | `number` | `0.002` | Minimum pixel ratio for secondary semantics |
| `junctionHRadiusPx` | `number` | `24` | Baseboard junction horizontal radius |
| `junctionVRadiusPx` | `number` | `2` | Baseboard junction vertical radius |
| `kickBridgeHalfWPx` | `number` | `6` | Baseboard horizontal gap bridge half-width |
| `baseboardJunctionRowMarginPx` | `number` | `1` | Baseboard junction row margin |
| `baseboardJunctionVReachPx` | `number` | `2` | Baseboard junction vertical reach |
| `baseboardMinRunPx` | `number` | `2` | Minimum run length for mask strips |
| `splitWalls` | `boolean` | `false` | Split wall mask into `wall-1`, `wall-2`… by texture boundaries |
| `splitWallsMaxCount` | `number` | `8` | Max wall sub-region count |
| `splitWallsMinAreaRatio` | `number` | `0.002` | Minimum area ratio for fragments (relative to total seg pixels) |
| `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

@ -0,0 +1,21 @@
---
id: paint-config
title: "Props: paintConfig"
---
# 🖌️ Props: paintConfig
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `palette` | `BgrColor[]` | 6-color built-in | Bottom brush color strip |
| `colorBaseOpacity` | `number` | `0.88` | Base color opacity |
| `lLightOpacity` | `number` | `0.50` | L-channel overlay intensity |
| `textureOpacity` | `number` | `0.85` | High-frequency texture overlay intensity (stronger texture preservation) |
| `lLowBlurKernel` | `number` | `7` | Low-frequency Gaussian kernel (odd number) |
| `lLowContrast` | `number` | `1.15` | Low-frequency contrast |
| `lLowBrightness` | `number` | `0.9` | Low-frequency brightness |
| `lHighGain` | `number` | `1.22` | High-frequency gain |
| `maskFeatherColor` | `number` | `1.6` | Paint edge feathering (color) — soft-edge alpha radius, in pixels |
| `maskFeatherTexture` | `number` | `0.9` | Paint edge feathering (texture) — reserved/auxiliary |
| `regionOverlayFill` | `string` | `rgba(20,120,235,0.58)` | Dashed line / highlight fill color |
| `regionOutlineStrokeWidth` | `number` | `4` | Dashed outline stroke width |

View File

@ -0,0 +1,16 @@
---
id: pipeline-config
title: "Props: pipelineConfig"
---
# 🔬 Props: pipelineConfig
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `maxImageLongSide` | `number` | `720` | Maximum long side for segmentation / pickMap / working area scaling |
| `paintFreqMaxLongSide` | `number` | `480` | Maximum long side for OpenCV LAB frequency layers |
| `originPreviewMaxLongSide` | `number` | `360` | Maximum long side for preview (main path uses working resolution) |
| `maskPathMaxLongSide` | `number` | `480` | Maximum long side for outline contour downsampling |
| `minContourArea` | `number` | `100` | Minimum contour area (scales proportionally with resolution) |
| `contourApproxEpsilon` | `number` | `0.003` | Contour polygon approximation coefficient |
| `maxRegions` | `number` | `500` | Maximum region count during segmentation |

View File

@ -0,0 +1,18 @@
---
id: props-image
title: "Props: Image & Initialization"
---
# 🖼️ Props: Image & Initialization
| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `originUrl` | `string` | yes* | — | Origin image URL (`file://`, absolute path, or `http(s)://`) |
| `maskUrl` | `string` | yes* | — | Mask image URL (semantic color-block image; recommended same dimensions as origin) |
| `originImgPath` | `string` | — | — | **Deprecated** — use `originUrl` |
| `maskImgPath` | `string` | — | — | **Deprecated** — use `maskUrl` |
| `initialSession` | `MaskSegmentSession` | no | — | Draft restored from MMKV etc.; automatically calls `loadSession` after regions are ready |
| `initialPaintColor` | `BgrColor` | no | — | **Optional**. Initial custom brush color `{ b, g, r }`; if omitted, no brush is selected by default; user must select a color or call `ref.setPaintColor` |
| `initialPaintConfigJson` | `Record<string, unknown>` | no | — | **Optional**. Accompanying brush config for `initialPaintColor`; passed back via `onPaintCallback` on successful paint |
\* At least one of `originUrl` / `maskUrl` is required per usage context.

View File

@ -0,0 +1,25 @@
---
id: props-semantic
title: "Props: Semantic Colors & Outline"
---
# 🎨 Props: Semantic Colors & Outline
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `semanticColors` | `MaskSemanticColor[]` | `MASK_SEMANTIC_COLORS` | Mask semantic recognition colors; equivalent to `maskConfig.semanticColors` |
| `regionOutlineColor` | `string` | `rgba(20, 120, 235, 0.58)` | Region dashed highlight color; equivalent to `paintConfig.regionOverlayFill` |
Top-level props take priority over nested `maskConfig` / `paintConfig`.
## MaskSemanticColor Structure
```ts
{
name: string; // Semantic name, e.g. wall / ceiling / baseboard
hex: string; // Display hex color
bgr: { b: number; g: number; r: number }; // Must match mask pixel BGR channels
}
```
Built-in palette: `MASK_SEMANTIC_COLORS` (see `src/utils/maskSemanticPalette.ts`).

View File

@ -0,0 +1,74 @@
---
id: ref-methods
title: "Ref Methods"
---
# 🔧 Ref Methods
Accessed via `ref` (type `MaskSegmentCanvasRef`):
| Method | Signature | Description |
| --- | --- | --- |
| `reset` | `() => void` | Undo last paint step (by `paintHistory`) |
| `swap` | `(showOrigin?: boolean) => void` | Toggle origin image comparison; omit arg to toggle, `true`/`false` to force |
| `save` | `(options?) => Promise<SavePaintResult>` | Composite and save PNG; `options.destDir` optional output directory |
| `session` | `() => MaskSegmentSession` | Export JSON-serializable session (for MMKV storage) |
| `loadSession` | `(session) => void` | Restore paint state (also available via `initialSession`) |
| `setPaintColor` | `(color, configJson?) => void` | Set current brush color; clears bottom color bar selection |
| `setMaskConfig` | `(config) => void` | Update mask config at runtime and **re-segment** |
| `clearAllPaint` | `() => void` | Clear all paint records |
| `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
const result = await ref.current?.save({ destDir: '/path/to/dir' });
const session = ref.current?.session();
ref.current?.loadSession(session);
ref.current?.setPaintColor({ b: 100, g: 120, r: 140 }, { sku: 'paint-001' });
ref.current?.setMaskConfig({ semanticColors: customColors });
ref.current?.clearAllPaint();
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.

26
docs/docs/api/storage.md Normal file
View File

@ -0,0 +1,26 @@
---
id: storage
title: "Storage Convention"
---
# 💾 Storage Convention
| Capability | Recommended Storage | Content |
| --- | --- | --- |
| `ref.save()` | File system | Full-res PNG path |
| `ref.session()` | MMKV / AsyncStorage | JSON metadata (URLs, paint records, brush color, etc.) |
## MaskSegmentSession Structure
```ts
{
version: 1;
originUrl: string;
maskUrl: string;
painted: PaintedRegionRecord[]; // { regionId, regionName, color, configJson? }
paintHistory: number[];
currentColor?: BgrColor;
currentColorConfigJson?: Record<string, unknown>;
savedAt: number;
}
```

View File

@ -0,0 +1,24 @@
---
id: ui-controls
title: "Props: UI Controls & Styling"
---
# 🎛️ Props: UI Controls & Styling
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showToolbar` | `boolean` | `true` | Top toolbar ("Clear cache & re-segment") |
| `showColorBar` | `boolean` | `true` | Bottom brush color strip |
| `showStatusRow` | `boolean` | `true` | Segmentation/loading status text |
| `showOverlayButtons` | `boolean` | `true` | Bottom-left undo, bottom-right compare buttons |
| `showDebugPickers` | `boolean` | `true` | Photo library debug picker (set to `false` in production) |
| `disabled` | `boolean` | `false` | Disable paint interaction |
| `style` | `ViewStyle` | — | Outer container style |
| `canvasStyle` | `ViewStyle` | — | Canvas area style |
| `undoButtonStyle` / `compareButtonStyle` | `ViewStyle` | — | Overlay button styles |
| `undoButtonTextStyle` / `compareButtonTextStyle` | `TextStyle` | — | Overlay button text styles |
| `undoButtonText` | `string` | `Undo` | Undo button label |
| `compareButtonText` | `string` | `Compare` | Enter compare mode label |
| `compareExitButtonText` | `string` | `Exit Compare` | Exit compare mode label |
| `renderUndoButton` | `(props) => ReactNode` | — | Custom undo button renderer |
| `renderCompareButton` | `(props) => ReactNode` | — | Custom compare button renderer |

203
docs/docs/basic-usage.md Normal file
View File

@ -0,0 +1,203 @@
---
id: basic-usage
title: Basic Usage
---
# 💡 Basic Usage
## 🧑‍💻 Minimal Example
A complete, copy-pasteable example covering **PNG pre-warming**, **state management**, **configuration**, **loading states**, **onWatch**, and **ref operations**.
```tsx
import React, { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import MaskSegmentCanvas, {
type MaskSegmentCanvasRef,
type MaskSegmentSession,
type MaskSegmentWatchState,
type BgrColor,
MASK_SEMANTIC_COLORS,
prewarmPngBgrCacheAsync,
} from 'react-native-mask-segment-canvas';
/** Image paths prepared by the host app (local file:// or http(s)://) */
type ImagePaths = {
origin: string;
mask: string;
};
const INTERACTIVE_STATES: MaskSegmentWatchState[] = [
'interactive',
'mask_paths_ready',
];
export function PaintScreen() {
const canvasRef = useRef<MaskSegmentCanvasRef>(null);
const [imagePaths, setImagePaths] = useState<ImagePaths | null>(null);
const [pathsError, setPathsError] = useState('');
const [watchState, setWatchState] = useState<MaskSegmentWatchState | ''>('');
const [errorMessage, setErrorMessage] = useState('');
const [sessionDraft] = useState<MaskSegmentSession | null>(null);
const isInteractive = INTERACTIVE_STATES.includes(
watchState as MaskSegmentWatchState,
);
const isOutlineReady = watchState === 'mask_paths_ready';
const isCanvasLoading =
imagePaths != null &&
watchState !== '' &&
!INTERACTIVE_STATES.includes(watchState as MaskSegmentWatchState) &&
watchState !== 'error';
useEffect(() => {
let cancelled = false;
(async () => {
try {
const origin = 'file:///path/to/origin.png';
const mask = 'file:///path/to/mask.png';
await prewarmPngBgrCacheAsync([origin, mask]);
if (!cancelled) {
setImagePaths({ origin, mask });
}
} catch (e) {
if (!cancelled) {
setPathsError(e instanceof Error ? e.message : String(e));
}
}
})();
return () => { cancelled = true; };
}, []);
const handleSave = async () => {
if (!isInteractive) return;
const result = await canvasRef.current?.save({ destDir: undefined });
console.log('saved', result?.filePath, result?.paintedCount);
};
if (pathsError) {
return <Text>{pathsError}</Text>;
}
if (!imagePaths) {
return (
<View>
<ActivityIndicator />
<Text>Waiting for origin and mask images...</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
{isCanvasLoading ? <Text>Loading: {watchState}</Text> : null}
{watchState === 'interactive' ? (
<Text>Paintable (outlines loading...)</Text>
) : null}
{isOutlineReady ? <Text>Ready</Text> : null}
{errorMessage ? <Text>{errorMessage}</Text> : null}
<MaskSegmentCanvas
ref={canvasRef}
style={{ flex: 1 }}
originUrl={imagePaths.origin}
maskUrl={imagePaths.mask}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="rgba(20, 120, 235, 0.58)"
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
pipelineConfig={{ maxImageLongSide: 720 }}
paintConfig={{ colorBaseOpacity: 0.88 }}
interactionConfig={{
initRegionFlashMs: 1000,
enableInitRegionFlash: true,
}}
initialSession={sessionDraft ?? undefined}
showDebugPickers={false}
showToolbar={false}
showColorBar
showStatusRow={false}
showOverlayButtons
disabled={!isInteractive}
onWatch={(state, durationMs, detail) => {
setWatchState(state);
console.log('[onWatch]', state, durationMs, detail);
}}
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
toast(payload.hint);
return;
}
console.log('painted', payload.regionId, payload.regionName, payload.color, payload.configJson);
}}
onError={message => {
setErrorMessage(message);
setWatchState('error');
}}
/>
</View>
);
}
```
---
## 📊 State Variables
| State | Type | Purpose |
| --- | --- | --- |
| `imagePaths` | `{ origin, mask } \| null` | Local/remote image paths resolved by the host |
| `pathsError` | `string` | Error message when path resolution or PNG pre-warming fails |
| `watchState` | `MaskSegmentWatchState \| ''` | Initialization stage reported by `onWatch` |
| `isInteractive` | derived | `true` when `interactive` or `mask_paths_ready` — operations are allowed |
| `isOutlineReady` | derived | `true` when `mask_paths_ready` — carousel dashed outlines are ready |
| `isCanvasLoading` | derived | Canvas init is blocking (not including PNG path waiting) |
| `errorMessage` | `string` | Segmentation/loading failure message written by `onError` |
| `sessionDraft` | `MaskSegmentSession \| null` | Draft restored from MMKV or similar storage |
---
## ⚙️ Choosing Configuration Values
| Config | Use top-level prop when... | Use nested Config when... |
| --- | --- | --- |
| Semantic colors | `semanticColors={...}` for most cases | `maskConfig.semanticColors` when paired with other mask params |
| Outline color | `regionOutlineColor="..."` for most cases | `paintConfig.regionOverlayFill` when also customizing the brush palette |
| Black threshold, max regions | — | `maskConfig` |
| Image processing size | — | `pipelineConfig` |
| Flash interval, tap tolerance | — | `interactionConfig` |
Top-level props and nested Configs **can coexist**; top-level `semanticColors` / `regionOutlineColor` take priority.
---
## 🔄 watchState & UI Guidance
```ts
// Blocking loading (before regions + paint layers are ready)
const isLoading = ![
'interactive',
'mask_paths_ready',
'error',
'',
].includes(watchState);
// Allow tapping regions, selecting colors, painting
const canOperate =
watchState === 'interactive' || watchState === 'mask_paths_ready';
// Carousel dashed outlines are fully ready
const isOutlineReady = watchState === 'mask_paths_ready';
// Show error screen
const hasError = watchState === 'error';
```
At `interactive`, `detail.maskPathsReady` is typically `false`; at `mask_paths_ready`, it is `true`. The gap is roughly ~100ms (async Skia path construction) and does not block tap-to-paint.
`originUrl` / `maskUrl` support:
- Local paths: `file:///...` or absolute paths
- Remote URLs: `http(s)://...` (the component handles download and decoding internally)
> Legacy props `originImgPath` / `maskImgPath` are deprecated; use `originUrl` / `maskUrl` instead.

17
docs/docs/dependencies.md Normal file
View File

@ -0,0 +1,17 @@
---
id: dependencies
title: Dependencies
---
# 📚 Dependencies
| Package | Purpose |
| --- | --- |
| `@shopify/react-native-skia` | Canvas rendering, Path, dashed strokes, Blend compositing |
| `react-native-fast-opencv` | Mask morphology, contour processing |
| `react-native-fs` | Layer caching, PNG save |
| `react-native-image-picker` | Demo photo library picker (optional) |
| `react-native-reanimated` | Skia animation dependency |
| `react-native-safe-area-context` | Safe area insets (optional) |
| `buffer` | Binary buffer polyfill |
| `upng-js` | PNG encoding/decoding in pure JS |

View File

@ -0,0 +1,25 @@
---
id: custom-colors
title: Custom Semantic Color Table
---
# 🎨 Custom Semantic Color Table
Define custom semantic colors when your mask uses different color values:
```tsx
const gymColors: MaskSemanticColor[] = [
{ name: 'wall', hex: '#4363D8', bgr: { b: 216, g: 99, r: 67 } },
{ name: 'ceiling', hex: '#3CB44B', bgr: { b: 75, g: 180, r: 60 } },
// ...
];
<MaskSegmentCanvas
semanticColors={gymColors}
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
/>
```
:::caution
`semanticColors` must match the semantic colors used in the backend/labeled mask; mismatch will cause recognition drift.
:::

View File

@ -0,0 +1,18 @@
---
id: draft-recovery
title: Draft Recovery
---
# 💾 Draft Recovery
```tsx
const draft = JSON.parse(mmkv.getString('paint_draft'));
<MaskSegmentCanvas
originUrl={draft.originUrl}
maskUrl={draft.maskUrl}
initialSession={draft}
/>
```
Use `ref.session()` to export the current session and store it in MMKV or AsyncStorage for later recovery.

View File

@ -0,0 +1,21 @@
---
id: local-paths
title: Passing Local Paths from an API
---
# 🌐 Passing Local Paths from an API
```tsx
<MaskSegmentCanvas
originUrl={localOriginPath}
maskUrl={localMaskPath}
showDebugPickers={false}
showToolbar={false}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="#1e96ff"
onWatch={(state, ms, detail) => {
if (state === 'interactive') hideBlockingLoader();
if (state === 'mask_paths_ready') hideOutlineHint();
}}
/>
```

View File

@ -0,0 +1,19 @@
---
id: png-pre-warm
title: Pre-warm PNG Cache (Recommended)
---
# 🔥 Pre-warm PNG Cache (Recommended)
Pre-warm the PNG decode cache before mounting the canvas to save **100250ms** on initialization.
```tsx
import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
async function openPaintScreen(originUrl: string, maskUrl: string) {
await prewarmPngBgrCacheAsync([originUrl, maskUrl]);
navigation.navigate('Paint', { originUrl, maskUrl });
}
```
Call `prewarmPngBgrCacheAsync` after download/extraction and before navigating to the paint screen for the best performance.

136
docs/docs/installation.md Normal file
View File

@ -0,0 +1,136 @@
---
id: installation
title: Installation
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# 📦 Installation
## Peer Dependencies
Install these in your host project (versions should match your host RN version):
```bash
npm install @shopify/react-native-skia react-native-reanimated react-native-fast-opencv react-native-fs buffer upng-js react-native-gesture-handler
# If using showDebugPickers (photo library picker)
npm install react-native-image-picker
# Safe area insets
npm install react-native-safe-area-context
```
## Postinstall Setup
This library relies on `patch-package` to patch `react-native-fast-opencv`. Your host `package.json` must include:
```json
{
"scripts": {
"postinstall": "patch-package"
},
"devDependencies": {
"patch-package": "^8.0.1"
}
}
```
After installing this library, patches from `node_modules/react-native-mask-segment-canvas/patches/` are applied automatically during the host's `postinstall`.
## iOS / Android Native Dependencies
```bash
cd ios && pod install && cd ..
```
Ensure the host project has completed Skia, Reanimated, and OpenCV native setup per each library's documentation.
## Metro Configuration
When using `npm link`, a monorepo, or `file:` dependencies, add this library to `watchFolders` and use `extraNodeModules` + `blockList` to prevent duplicate module resolution:
```js
const path = require('path');
module.exports = {
watchFolders: [path.resolve(__dirname, '../MaskSegmentApp')],
resolver: {
nodeModulesPaths: [path.resolve(__dirname, 'node_modules')],
extraNodeModules: {
'react-native-reanimated': path.resolve(__dirname, 'node_modules/react-native-reanimated'),
'@shopify/react-native-skia': path.resolve(__dirname, 'node_modules/@shopify/react-native-skia'),
'react-native-gesture-handler': path.resolve(__dirname, 'node_modules/react-native-gesture-handler'),
'react-native-fast-opencv': path.resolve(__dirname, 'node_modules/react-native-fast-opencv'),
'react-native-safe-area-context': path.resolve(__dirname, 'node_modules/react-native-safe-area-context'),
'react-native-fs': path.resolve(__dirname, 'node_modules/react-native-fs'),
},
blockList: [
/\/MaskSegmentApp\/node_modules\/@shopify\/react-native-skia\//,
/\/MaskSegmentApp\/node_modules\/react-native-reanimated\//,
/\/MaskSegmentApp\/node_modules\/react-native-fast-opencv\//,
/\/MaskSegmentApp\/node_modules\/react-native-gesture-handler\//,
/\/MaskSegmentApp\/node_modules\/react-native-safe-area-context\//,
/\/MaskSegmentApp\/node_modules\/react-native-fs\//,
],
},
};
```
**Strongly recommended** — add this at the very top of the host `index.js` (before any business code):
```js
import '@shopify/react-native-skia';
```
See `example/metro.config.js` and `example/index.js` for the complete configuration with all peer singleton packages.
## Troubleshooting: Duplicate Module Errors
Common symptoms:
- `SkiaPictureView must be a function (received 'undefined')`
- `createAnimatedNode: Animated node[...] already exists`
These are almost always caused by Metro resolving multiple copies of reanimated / skia / gesture-handler / fast-opencv / safe-area packages.
**Best practice:**
1. Copy the `singletonPackages` + `extraNodeModules` + `blockList` pattern from `example/metro.config.js`
2. At the top of your `index.js`, import gesture-handler → reanimated → skia in order
3. Restart Metro with `--reset-cache` and reinstall the app
See the example project for a detailed checklist and template.
### Integration Methods
<Tabs>
<TabItem value="npm" label="npm install (Production)">
```bash
npm install react-native-mask-segment-canvas
```
</TabItem>
<TabItem value="link" label="npm link (Development)">
```bash
# In the library directory
npm link
# In your project
npm link react-native-mask-segment-canvas
```
</TabItem>
<TabItem value="file" label="file: dependency">
```json
{
"dependencies": {
"react-native-mask-segment-canvas": "file:../MaskSegmentApp"
}
}
```
</TabItem>
</Tabs>

View File

@ -0,0 +1,37 @@
---
id: interaction-guide
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

56
docs/docs/intro.md Normal file
View File

@ -0,0 +1,56 @@
---
id: intro
title: Overview
sidebar_position: 1
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# 🎨 react-native-mask-segment-canvas
A React Native **0.79** interactive mask segmentation library. The core export is the `MaskSegmentCanvas` component, consumable via **npm package** or **npm link** from any React Native project.
- 🧠 **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`).
📌 **For the recommended integration demo, see the `example/` directory** — it uses only the public API, fully simulating how a consumer project would integrate (including `package.json`, Metro configuration, and a complete reference `App.tsx`).
---
## 🔭 Overview
`MaskSegmentCanvas` renders an original image with an overlaid semantic mask, allowing users to tap regions and apply colors. The pipeline:
1. 📥 **Load** the origin image and mask image (local `file://` or remote `http(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. 🧲 **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.
---
## 📋 Requirements
- 🟢 Node.js >= 18 (recommended 20+)
- 🍎 Xcode 15+ (iOS)
- 🤖 Android Studio + JDK 17 (Android)
- 📦 CocoaPods (iOS)
---
## 🚀 Next Steps
- **[Installation](/docs/installation)** — set up the library in your project
- **[Quick Start](/docs/quick-start)** — run the dev demo
- **[Basic Usage](/docs/basic-usage)** — minimal example to get started
- **[API Reference](/docs/api)** — full prop and method documentation

11
docs/docs/notes.md Normal file
View File

@ -0,0 +1,11 @@
---
id: notes
title: Notes
---
# 📝 Notes
- The mask image should be a semantic color-block image with the same dimensions as the origin (black background + solid-color regions). Pixels with `max(B,G,R) < blackThreshold` (default 30) are excluded from segmentation.
- OpenCV segmentation runs on the JS thread; very large images may cause frame drops. Use `pipelineConfig.maxImageLongSide` to cap processing resolution.
- iOS photo library access requires photo permissions (only needed when `showDebugPickers` is enabled).
- `semanticColors` must match the semantic colors used in the backend/labeled mask; mismatch will cause recognition drift.

92
docs/docs/performance.md Normal file
View File

@ -0,0 +1,92 @@
---
id: performance
title: Performance
---
# ⚡ Performance
The data below is based on the Demo test image (`assets/test/origin.png` **1080×1920**, 6 semantic regions), **default `pipelineConfig`**, and `onWatch` `durationMs` (measured from `init`). These are **empirical ranges**, not strict benchmarks; actual device results vary with CPU, storage, and RN version.
## Measured Reference (Dev Env + PNG Pre-warming)
The Demo calls `prewarmPngBgrCacheAsync([origin, mask])` before mounting the canvas, so PNG decoding hits the memory cache. Typical logs:
| Stage | watchState | Approx. Duration | Notes |
| --- | --- | --- | --- |
| Mask aligned | `mask_aligned` | ~160ms | Mask scaled to segmentation working resolution |
| Regions ready | `regions_ready` / `mask_sampled` | ~320ms | Layout scan + baseboard + pickMap |
| **Interactive** | `**interactive`** | **~320450ms** | Can tap regions, select colors, Shader paint |
| Outlines ready | `mask_paths_ready` | ~430550ms | ~100ms after `interactive`; carousel outlines can display |
`interactive` does **not wait** for outline paths; `mask_paths_ready` only affects the initial carousel and optional UI hints.
Same-image sub-step magnitudes (`__DEV__` logs, default pipeline):
| Sub-step | Approx. Duration | Working Resolution |
| --- | --- | --- |
| OpenCV LAB high/low freq | ~1040ms | 270×480 |
| High/low freq Skia textures | ~2030ms | same |
| Layout scan + baseboard + pick table | ~90120ms | 405×720 (1080p → longSide 720) |
| Full contour paths (async, non-blocking) | ~80150ms | 270×480 |
## Resolution vs pipelineConfig
Compute-intensive steps are capped by **maximum long side limits** and do **not scale linearly with 4K/8K origin images**. **Full PNG decoding** still scales linearly with pixel count.
| Step | Config Key | 1080×1920 Actual Size | Scales with Origin Pixels |
| --- | --- | --- | --- |
| PNG decode | — | 1080×1920 × 2 images | **Yes** |
| Mask seg / pickMap | `maxImageLongSide: 720` | ~405×720 | **No** (fixed when long side >720) |
| Shader high/low freq | `paintFreqMaxLongSide: 480` | ~270×480 | **No** |
| Working area Skia origin | same as `maxImageLongSide` | ~405×720 | **No** |
| Dashed outlines | `maskPathMaxLongSide: 480` | ~270×480 | **No** (does not block `interactive`) |
## interactive Estimation (Default Pipeline)
| Origin Spec | Relative to 1080p Pixels | With PNG Pre-warm | Cold Start (no pre-warm) |
| --- | --- | --- | --- |
| 1080×1920 | 1× | **320450ms** | **450700ms** |
| 1440×2560 (2K) | ~1.8× | **400550ms** | **600900ms** |
| 3840×2160 (4K) | ~4× | **500750ms** | **8001200ms** |
| 7680×4320 (8K) | ~16× | **0.81.5s** | **1.53s+** |
> **`<300ms` interactive**: achievable on 1080p + pre-warm + default pipeline + high-end devices, but **optimistic** — do not treat as an all-device SLA.
## Device Tier (1080p, Default Pipeline)
Relative to the ~320ms dev-environment baseline:
| Tier | Relative Multiplier | Pre-warm `interactive` | Cold Start |
| --- | --- | --- | --- |
| Flagship iOS / new flagship Android | 0.81.2× | 300450ms | 500800ms |
| Mid-range Android | 1.52.5× | 500800ms | 700ms1.2s |
| Low-end Android (4GB, old SoC) | 2.54× | 800ms1.3s | 12s+ |
Android overhead primarily comes from: JS ↔ OpenCV bridge, memory bandwidth/GC, Skia texture upload.
## Impact of Raising maxImageLongSide
Setting `pipelineConfig.maxImageLongSide` to **1280** (above the default 720) results in a segmentation working area of ~720×1280, roughly **3×** the pixel count of the 720 tier:
| Scenario | Default 720 | Raised to 1280 |
| --- | --- | --- |
| 1080p `interactive` (mid-range) | ~320800ms | **500ms1s+** |
| Segmentation / pickMap duration | ~90120ms | ~250350ms |
Higher precision for longer init time. To stay **`<500ms` interactive**, keep the default **720**; reduce to **640** if needed.
## Optimization Tips
1. 🚀 **PNG pre-warming (recommended)**: Call `prewarmPngBgrCacheAsync` after download/extraction and before navigating to the paint screen. Typically saves **100250ms** (greatest benefit on low-end devices).
```tsx
import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
await prewarmPngBgrCacheAsync([originPath, maskPath]);
// Then mount MaskSegmentCanvas
```
2. ⏱️ **Loading timing**: Dismiss the blocking loader at `interactive`; optionally listen for `mask_paths_ready` for "outlines preparing" hints.
3. 🖼️ **Large images / low-end devices**: Keep default `maxImageLongSide: 720`; optionally lower `paintFreqMaxLongSide` to **360**.
4. 📷 **4K assets**: Downsample on the host side before passing in, or accept ~0.81.5s `interactive` (with pre-warm).
5. 🔍 **Observability**: Watch Metro logs for `[MaskSegment]`, `[⏱ ...]` prefixes and `onWatch` `durationMs`.

View File

@ -0,0 +1,33 @@
---
id: project-structure
title: Project Structure
---
# 📁 Project Structure
```
MaskSegmentApp/ # Repo root (npm package react-native-mask-segment-canvas)
├── App.tsx # Dev self-test Demo (imports from ./src directly)
├── src/
│ ├── index.ts # Package entry (consumer: import 'react-native-mask-segment-canvas')
│ ├── components/
│ │ ├── MaskSegmentCanvas.tsx
│ │ └── MaskSegmentCanvas.types.ts
│ └── utils/
│ ├── 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
│ ├── index.js / app.json
│ ├── package.json # Required deps + "react-native-mask-segment-canvas": "file:.."
│ ├── metro.config.js / babel.config.js / tsconfig.json
│ └── README.md # How to integrate in a real project
├── patches/ # Shipped with the package; applied by host postinstall
├── ios/ # Root Demo native project (not published to npm)
└── android/
```

25
docs/docs/quick-start.md Normal file
View File

@ -0,0 +1,25 @@
---
id: quick-start
title: Quick Start (Dev Demo)
---
# 🚀 Quick Start (Dev Demo)
The root `App.tsx` is a full self-test demo that imports directly from `./src`.
```bash
cd MaskSegmentApp
npm install
cd ios && bundle exec pod install && cd ..
npm start
# In another terminal
npm run ios
# or
npm run android
```
**To see how a consumer project integrates:** go to the `example/` directory and follow its `README.md`. It uses `import from 'react-native-mask-segment-canvas'` with standard `package.json` and Metro config, fully simulating a consumer environment.

View File

@ -0,0 +1,42 @@
---
id: troubleshooting
title: Troubleshooting
---
# 🔧 Troubleshooting
## iOS pod install fails
```bash
cd ios
bundle install
bundle exec pod install --repo-update
```
## Android build errors
```bash
cd android && ./gradlew clean && cd ..
```
## Segmentation fails / zero regions
- Verify `originUrl` / `maskUrl` are accessible
- Confirm mask semantic colors match the `semanticColors` config
- Check Metro logs for `[MaskSegment]` / `[⏱ ...]` output
## Dashed outlines misaligned / extra contours
- Outlines are generated from mask pixel external contours; long-press only shows the connected component at the touch point
- The initial carousel only shows the largest connected component for each semantic region
## Common Duplicate Module Errors
**Symptoms:**
- `SkiaPictureView must be a function (received 'undefined')`
- `createAnimatedNode: Animated node[...] already exists`
**Solution:**
1. Copy the `singletonPackages` + `extraNodeModules` + `blockList` pattern from `example/metro.config.js`
2. At the top of your `index.js`, import gesture-handler → reanimated → skia in order
3. Restart Metro with `--reset-cache` and reinstall the app

121
docs/docusaurus.config.ts Normal file
View File

@ -0,0 +1,121 @@
import { themes as prismThemes } from 'prism-react-renderer';
import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'MaskSegmentCanvas',
tagline: 'React Native interactive mask segmentation with OpenCV + Skia',
favicon: 'img/favicon.ico',
url: 'https://tonychan-hub.github.io',
baseUrl: '/react-native-mask-segment-canvas/',
organizationName: 'TonyChan-hub',
projectName: 'react-native-mask-segment-canvas',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
i18n: {
defaultLocale: 'en',
locales: ['en', 'zh-CN'],
localeConfigs: {
en: { label: 'English' },
'zh-CN': { label: '简体中文' },
},
},
presets: [
[
'classic',
{
docs: {
sidebarPath: './sidebars.ts',
editUrl: 'https://github.com/TonyChan-hub/react-native-mask-segment-canvas/tree/main/docs/',
},
theme: {
customCss: './src/css/custom.css',
},
} satisfies Preset.Options,
],
],
themeConfig: {
image: 'img/og-image.png',
navbar: {
title: 'MaskSegmentCanvas',
logo: {
alt: 'MaskSegmentCanvas Logo',
src: 'img/logo.svg',
},
items: [
{
type: 'docSidebar',
sidebarId: 'docsSidebar',
position: 'left',
label: 'Docs',
},
{
to: 'docs/api',
label: 'API',
position: 'left',
},
{
type: 'localeDropdown',
position: 'right',
},
{
href: 'https://github.com/TonyChan-hub/react-native-mask-segment-canvas',
label: 'GitHub',
position: 'right',
},
{
href: 'https://www.npmjs.com/package/react-native-mask-segment-canvas',
label: 'npm',
position: 'right',
},
],
},
footer: {
style: 'dark',
links: [
{
title: 'Docs',
items: [
{ label: 'Overview', to: 'docs/intro' },
{ label: 'Installation', to: 'docs/installation' },
{ label: 'Basic Usage', to: 'docs/basic-usage' },
{ label: 'API Reference', to: 'docs/api' },
],
},
{
title: 'Community',
items: [
{ label: 'GitHub', href: 'https://github.com/TonyChan-hub/react-native-mask-segment-canvas' },
{ label: 'npm', href: 'https://www.npmjs.com/package/react-native-mask-segment-canvas' },
],
},
{
title: 'More',
items: [
{ label: 'Performance', to: 'docs/performance' },
{ label: 'Troubleshooting', to: 'docs/troubleshooting' },
{ label: 'Example Project', to: 'docs/project-structure' },
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} MaskSegmentCanvas. Built with Docusaurus.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ['bash', 'json', 'typescript'],
},
colorMode: {
defaultMode: 'dark',
disableSwitch: false,
respectPrefersColorScheme: true,
},
} satisfies Preset.ThemeConfig,
};
export default config;

View File

@ -0,0 +1,72 @@
---
id: callbacks
title: "Props回调"
---
# 📞 Props回调
| Prop | 签名 | 描述 |
| --- | --- | --- |
| `onWatch` | `(state, durationMs, detail?) => void` | 初始化阶段回调;`durationMs` 从本次 `init` 开始计时 |
| `onPaintCallback` | `(payload: PaintCallbackPayload) => void` | 成功上色时触发,或未选择画笔时点击区域触发 |
| `onError` | `(message, error?) => void` | 分割或加载失败 |
## PaintCallbackPayload
(可辨识联合类型,通过 `payload.kind` 区分):
```ts
// 成功上色
{
kind: 'painted';
regionId: number;
regionName: string;
color: BgrColor;
configJson?: Record<string, unknown>; // 来自 setPaintColor / initialPaintConfigJson
}
// 点击了有效区域但未选择画笔(未执行上色)
{
kind: 'brush_required';
hint: string; // 如 "请先选择画笔颜色(底部颜色条或 ref.setPaintColor"
regionId: number;
regionName: string;
}
```
示例:
```tsx
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
showToast(payload.hint);
return;
}
savePaintRecord(payload.regionId, payload.color, payload.configJson);
}}
```
## onWatch detailMaskSegmentWatchDetail
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `regionCount` | `number` | 当前有效区域数 |
| `maskPathsReady` | `boolean` | 轮廓 Skia 路径是否就绪 |
| `freqLayersReady` | `boolean` | 频域 Shader 纹理是否就绪 |
| `errorMessage` | `string` | `error` 状态下的失败描述 |
### onWatch 状态流转
```
init
→ images_loaded 原始图像和遮罩读取完成
→ mask_aligned 遮罩尺寸对齐
→ mask_sampled 遮罩像素采样完成
→ regions_ready 区域提取成功
→ layers_ready 上色纹理层就绪detail.maskPathsReady 可能仍为 false
→ interactive 可交互(可以点击区域、选择颜色、上色)
→ mask_paths_ready 轮廓路径就绪轮播虚线轮廓可显示detail.maskPathsReady 为 true
→ error 失败detail.errorMessage 包含描述)
```
`layers_ready` / `interactive` 可能在轮廓路径计算完成之前触发。如果宿主在 `interactive` 时关闭阻塞加载器,用户已可操作;轮播虚线轮廓在 `mask_paths_ready` 后自动显示。

View File

@ -0,0 +1,65 @@
---
id: index
title: API 参考
---
# 📖 API 参考
## 导入
```tsx
import MaskSegmentCanvas, {
type MaskSegmentCanvasRef,
type MaskSegmentCanvasProps,
type MaskSegmentSession,
type MaskSegmentWatchState,
type MaskSegmentWatchDetail,
type BgrColor,
type MaskSemanticColor,
type PaintCallbackPayload,
type PaintedRegionRecord,
type PipelineConfig,
type MaskSegmentConfig,
type PaintConfig,
type InteractionConfig,
type SavePaintResult,
type LassoPolygon,
type ManualWallPartition,
MASK_SEMANTIC_COLORS,
BASEBOARD_SEMANTIC_NAME,
prewarmPngBgrCacheAsync,
DEFAULT_PIPELINE_CONFIG,
DEFAULT_MASK_CONFIG,
DEFAULT_PAINT_CONFIG,
DEFAULT_INTERACTION_CONFIG,
} from 'react-native-mask-segment-canvas';
```
| 分类 | 名称 |
| --- | --- |
| 组件 | `MaskSegmentCanvas`(默认导出) |
| Ref / Props 类型 | `MaskSegmentCanvasRef`, `MaskSegmentCanvasProps` |
| 会话 / 回调类型 | `MaskSegmentSession`, `PaintCallbackPayload`, `PaintedRegionRecord`, `SavePaintResult` |
| 套索类型 | `LassoPolygon`, `ManualWallPartition` |
| Watch 类型 | `MaskSegmentWatchState`, `MaskSegmentWatchDetail` |
| 配置类型 | `PipelineConfig`, `MaskSegmentConfig`, `PaintConfig`, `InteractionConfig` |
| 语义颜色 | `MASK_SEMANTIC_COLORS`, `BASEBOARD_SEMANTIC_NAME` |
| 工具函数 | `prewarmPngBgrCacheAsync` |
| 运行时默认值 | `DEFAULT_*_CONFIG` |
---
## Props 概览
| 分类 | 描述 |
| --- | --- |
| [图像与初始化](/docs/api/props-image) | `originUrl`, `maskUrl`, `initialSession`, `initialPaintColor` |
| [语义颜色与轮廓](/docs/api/props-semantic) | `semanticColors`, `regionOutlineColor` |
| [maskConfig](/docs/api/mask-config) | 分割和语义区域配置 |
| [pipelineConfig](/docs/api/pipeline-config) | 分辨率和处理管线配置 |
| [paintConfig](/docs/api/paint-config) | 上色渲染和纹理混合配置 |
| [interactionConfig](/docs/api/interaction-config) | 触摸交互和命中测试配置 |
| [UI 控件与样式](/docs/api/ui-controls) | 可见性开关、自定义渲染器、样式 |
| [回调](/docs/api/callbacks) | `onWatch`, `onPaintCallback`, `onError` |
| [Ref 方法](/docs/api/ref-methods) | 通过 `ref` 调用的命令式方法 |
| [存储约定](/docs/api/storage) | 会话持久化和 PNG 导出 |

View File

@ -0,0 +1,15 @@
---
id: interaction-config
title: "PropsinteractionConfig"
---
# 👆 PropsinteractionConfig
| 字段 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `pickMapSearchRadiusPx` | `number` | `14` | 点击 pickMap 搜索半径(像素) |
| `kickMaskPickRadiusPx` | `number` | `36` | 踢脚线遮罩拾取半径 |
| `thinStripPadding` | `number` | `0.008` | 细条(踢脚线)点击扩展比例 |
| `regionPadding` | `number` | `0.003` | 普通区域点击扩展比例 |
| `initRegionFlashMs` | `number` | `1000` | 初始轮播中每条虚线轮廓持续时长ms |
| `enableInitRegionFlash` | `boolean` | `true` | 启用初始轮播动画 |

View File

@ -0,0 +1,51 @@
---
id: mask-config
title: "PropsmaskConfig"
---
# 🧩 PropsmaskConfig
| 字段 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `semanticColors` | `MaskSemanticColor[]` | 内置调色板 | 遮罩语义颜色(可被顶层 `semanticColors` 覆盖) |
| `blackThreshold` | `number` | `30` | max(B,G,R) 低于此值的像素视为黑色背景 |
| `maxRegionColors` | `number` | `6` | 保留的最大语义区域数 |
| `quantStep` | `number` | `64` | 踢脚线量化步长 |
| `baseboardMaxColorDist` | `number` | `42` | 踢脚线颜色距离阈值 |
| `baseboardStripQuantKeys` | `string[]` | 内置键值 | 踢脚线索带量化键,格式 `"b,g,r"` |
| `wallQuantKeys` | `string[]` | 内置键值 | 墙面量化键 |
| `cabinetQuantKeys` | `string[]` | 内置键值 | 橱柜量化键 |
| `secondarySemanticNames` | `string[]` | `garageDoor, roof, eave` | 次要语义名称 |
| `secondaryMinPixelRatio` | `number` | `0.002` | 次要语义的最小像素比例 |
| `junctionHRadiusPx` | `number` | `24` | 踢脚线接缝水平半径 |
| `junctionVRadiusPx` | `number` | `2` | 踢脚线接缝垂直半径 |
| `kickBridgeHalfWPx` | `number` | `6` | 踢脚线水平间隙桥接半宽 |
| `baseboardJunctionRowMarginPx` | `number` | `1` | 踢脚线接缝行边距 |
| `baseboardJunctionVReachPx` | `number` | `2` | 踢脚线接缝垂直延伸 |
| `baseboardMinRunPx` | `number` | `2` | 遮罩条带最小运行长度 |
| `splitWalls` | `boolean` | `false` | 将墙面遮罩按纹理边界拆分为 `wall-1`、`wall-2`... |
| `splitWallsMaxCount` | `number` | `8` | 最大墙面子区域数 |
| `splitWallsMinAreaRatio` | `number` | `0.002` | 碎片最小面积比例(相对于总分割像素) |
| `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

@ -0,0 +1,21 @@
---
id: paint-config
title: "PropspaintConfig"
---
# 🖌️ PropspaintConfig
| 字段 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `palette` | `BgrColor[]` | 6色内置调色板 | 底部画笔颜色条 |
| `colorBaseOpacity` | `number` | `0.88` | 基础颜色不透明度 |
| `lLightOpacity` | `number` | `0.50` | L 通道叠加强度 |
| `textureOpacity` | `number` | `0.85` | 高频纹理叠加强度(更强的纹理保留效果) |
| `lLowBlurKernel` | `number` | `7` | 低频高斯核(奇数) |
| `lLowContrast` | `number` | `1.15` | 低频对比度 |
| `lLowBrightness` | `number` | `0.9` | 低频亮度 |
| `lHighGain` | `number` | `1.22` | 高频增益 |
| `maskFeatherColor` | `number` | `1.6` | 上色边缘羽化(颜色)— 软边缘 alpha 半径,单位像素 |
| `maskFeatherTexture` | `number` | `0.9` | 上色边缘羽化(纹理)— 保留/辅助 |
| `regionOverlayFill` | `string` | `rgba(20,120,235,0.58)` | 虚线 / 高亮填充颜色 |
| `regionOutlineStrokeWidth` | `number` | `4` | 虚线轮廓描边宽度 |

View File

@ -0,0 +1,16 @@
---
id: pipeline-config
title: "PropspipelineConfig"
---
# 🔬 PropspipelineConfig
| 字段 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `maxImageLongSide` | `number` | `720` | 分割 / pickMap / 工作区域缩放的最大长边 |
| `paintFreqMaxLongSide` | `number` | `480` | OpenCV LAB 频域层的最大长边 |
| `originPreviewMaxLongSide` | `number` | `360` | 预览最大长边(主路径使用工作分辨率) |
| `maskPathMaxLongSide` | `number` | `480` | 轮廓路径下采样的最大长边 |
| `minContourArea` | `number` | `100` | 最小轮廓面积(按分辨率比例缩放) |
| `contourApproxEpsilon` | `number` | `0.003` | 轮廓多边形近似系数 |
| `maxRegions` | `number` | `500` | 分割期间的最大区域数 |

View File

@ -0,0 +1,18 @@
---
id: props-image
title: "Props图像与初始化"
---
# 🖼️ Props图像与初始化
| Prop | 类型 | 必填 | 默认值 | 描述 |
| --- | --- | --- | --- | --- |
| `originUrl` | `string` | 是* | — | 原始图像 URL`file://`、绝对路径或 `http(s)://` |
| `maskUrl` | `string` | 是* | — | 遮罩图像 URL语义色块图建议与原始图像尺寸相同 |
| `originImgPath` | `string` | — | — | **已弃用** — 请使用 `originUrl` |
| `maskImgPath` | `string` | — | — | **已弃用** — 请使用 `maskUrl` |
| `initialSession` | `MaskSegmentSession` | 否 | — | 从 MMKV 等恢复的草稿;区域就绪后自动调用 `loadSession` |
| `initialPaintColor` | `BgrColor` | 否 | — | **可选**。初始自定义画笔颜色 `{ b, g, r }`;省略时默认不选中画笔;用户需选择颜色或调用 `ref.setPaintColor` |
| `initialPaintConfigJson` | `Record<string, unknown>` | 否 | — | **可选**。`initialPaintColor` 的附带画笔配置;成功上色时通过 `onPaintCallback` 返回 |
\* 使用时至少需要 `originUrl` / `maskUrl` 之一。

View File

@ -0,0 +1,25 @@
---
id: props-semantic
title: "Props语义颜色与轮廓"
---
# 🎨 Props语义颜色与轮廓
| Prop | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `semanticColors` | `MaskSemanticColor[]` | `MASK_SEMANTIC_COLORS` | 遮罩语义识别颜色;等同于 `maskConfig.semanticColors` |
| `regionOutlineColor` | `string` | `rgba(20, 120, 235, 0.58)` | 区域虚线高亮颜色;等同于 `paintConfig.regionOverlayFill` |
顶层 props 优先于嵌套的 `maskConfig` / `paintConfig`
## MaskSemanticColor 结构
```ts
{
name: string; // 语义名称,如 wall / ceiling / baseboard
hex: string; // 显示用的十六进制颜色
bgr: { b: number; g: number; r: number }; // 必须与遮罩像素 BGR 通道匹配
}
```
内置调色板:`MASK_SEMANTIC_COLORS`(详见 `src/utils/maskSemanticPalette.ts`)。

View File

@ -0,0 +1,74 @@
---
id: ref-methods
title: "Ref 方法"
---
# 🔧 Ref 方法
通过 `ref` 访问(类型 `MaskSegmentCanvasRef`
| 方法 | 签名 | 描述 |
| --- | --- | --- |
| `reset` | `() => void` | 撤销上一步上色操作(按 `paintHistory` |
| `swap` | `(showOrigin?: boolean) => void` | 切换原始图像对比;省略参数则切换,`true`/`false` 强制设置 |
| `save` | `(options?) => Promise<SavePaintResult>` | 合成并保存 PNG`options.destDir` 可选输出目录 |
| `session` | `() => MaskSegmentSession` | 导出 JSON 可序列化会话(用于 MMKV 存储) |
| `loadSession` | `(session) => void` | 恢复上色状态(也可通过 `initialSession` 使用) |
| `setPaintColor` | `(color, configJson?) => void` | 设置当前画笔颜色;清除底部颜色条选中状态 |
| `setMaskConfig` | `(config) => void` | 运行时更新遮罩配置并 **重新分割** |
| `clearAllPaint` | `() => void` | 清除所有上色记录 |
| `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); // 强制显示原始图像
const result = await ref.current?.save({ destDir: '/path/to/dir' });
const session = ref.current?.session();
ref.current?.loadSession(session);
ref.current?.setPaintColor({ b: 100, g: 120, r: 140 }, { sku: 'paint-001' });
ref.current?.setMaskConfig({ semanticColors: customColors });
ref.current?.clearAllPaint();
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

@ -0,0 +1,26 @@
---
id: storage
title: "存储约定"
---
# 💾 存储约定
| 能力 | 推荐存储 | 内容 |
| --- | --- | --- |
| `ref.save()` | 文件系统 | 全分辨率 PNG 路径 |
| `ref.session()` | MMKV / AsyncStorage | JSON 元数据URL、上色记录、画笔颜色等 |
## MaskSegmentSession 结构
```ts
{
version: 1;
originUrl: string;
maskUrl: string;
painted: PaintedRegionRecord[]; // { regionId, regionName, color, configJson? }
paintHistory: number[];
currentColor?: BgrColor;
currentColorConfigJson?: Record<string, unknown>;
savedAt: number;
}
```

View File

@ -0,0 +1,24 @@
---
id: ui-controls
title: "PropsUI 控件与样式"
---
# 🎛️ PropsUI 控件与样式
| Prop | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `showToolbar` | `boolean` | `true` | 顶部工具栏("清除缓存并重新分割" |
| `showColorBar` | `boolean` | `true` | 底部画笔颜色条 |
| `showStatusRow` | `boolean` | `true` | 分割/加载状态文字 |
| `showOverlayButtons` | `boolean` | `true` | 左下撤销、右下对比按钮 |
| `showDebugPickers` | `boolean` | `true` | 相册调试选择器(生产环境设为 `false` |
| `disabled` | `boolean` | `false` | 禁用上色交互 |
| `style` | `ViewStyle` | — | 外层容器样式 |
| `canvasStyle` | `ViewStyle` | — | 画布区域样式 |
| `undoButtonStyle` / `compareButtonStyle` | `ViewStyle` | — | 覆盖按钮样式 |
| `undoButtonTextStyle` / `compareButtonTextStyle` | `TextStyle` | — | 覆盖按钮文字样式 |
| `undoButtonText` | `string` | `Undo` | 撤销按钮标签 |
| `compareButtonText` | `string` | `Compare` | 进入对比模式标签 |
| `compareExitButtonText` | `string` | `Exit Compare` | 退出对比模式标签 |
| `renderUndoButton` | `(props) => ReactNode` | — | 自定义撤销按钮渲染器 |
| `renderCompareButton` | `(props) => ReactNode` | — | 自定义对比按钮渲染器 |

View File

@ -0,0 +1,203 @@
---
id: basic-usage
title: 基本用法
---
# 💡 基本用法
## 🧑‍💻 最小示例
一个可直接复制粘贴的完整示例,涵盖 **PNG 预热**、**状态管理**、**配置**、**加载状态**、**onWatch** 和 **ref 操作**
```tsx
import React, { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import MaskSegmentCanvas, {
type MaskSegmentCanvasRef,
type MaskSegmentSession,
type MaskSegmentWatchState,
type BgrColor,
MASK_SEMANTIC_COLORS,
prewarmPngBgrCacheAsync,
} from 'react-native-mask-segment-canvas';
/** 由宿主应用准备的图像路径(本地 file:// 或 http(s):// */
type ImagePaths = {
origin: string;
mask: string;
};
const INTERACTIVE_STATES: MaskSegmentWatchState[] = [
'interactive',
'mask_paths_ready',
];
export function PaintScreen() {
const canvasRef = useRef<MaskSegmentCanvasRef>(null);
const [imagePaths, setImagePaths] = useState<ImagePaths | null>(null);
const [pathsError, setPathsError] = useState('');
const [watchState, setWatchState] = useState<MaskSegmentWatchState | ''>('');
const [errorMessage, setErrorMessage] = useState('');
const [sessionDraft] = useState<MaskSegmentSession | null>(null);
const isInteractive = INTERACTIVE_STATES.includes(
watchState as MaskSegmentWatchState,
);
const isOutlineReady = watchState === 'mask_paths_ready';
const isCanvasLoading =
imagePaths != null &&
watchState !== '' &&
!INTERACTIVE_STATES.includes(watchState as MaskSegmentWatchState) &&
watchState !== 'error';
useEffect(() => {
let cancelled = false;
(async () => {
try {
const origin = 'file:///path/to/origin.png';
const mask = 'file:///path/to/mask.png';
await prewarmPngBgrCacheAsync([origin, mask]);
if (!cancelled) {
setImagePaths({ origin, mask });
}
} catch (e) {
if (!cancelled) {
setPathsError(e instanceof Error ? e.message : String(e));
}
}
})();
return () => { cancelled = true; };
}, []);
const handleSave = async () => {
if (!isInteractive) return;
const result = await canvasRef.current?.save({ destDir: undefined });
console.log('saved', result?.filePath, result?.paintedCount);
};
if (pathsError) {
return <Text>{pathsError}</Text>;
}
if (!imagePaths) {
return (
<View>
<ActivityIndicator />
<Text>Waiting for origin and mask images...</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
{isCanvasLoading ? <Text>Loading: {watchState}</Text> : null}
{watchState === 'interactive' ? (
<Text>Paintable (outlines loading...)</Text>
) : null}
{isOutlineReady ? <Text>Ready</Text> : null}
{errorMessage ? <Text>{errorMessage}</Text> : null}
<MaskSegmentCanvas
ref={canvasRef}
style={{ flex: 1 }}
originUrl={imagePaths.origin}
maskUrl={imagePaths.mask}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="rgba(20, 120, 235, 0.58)"
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
pipelineConfig={{ maxImageLongSide: 720 }}
paintConfig={{ colorBaseOpacity: 0.88 }}
interactionConfig={{
initRegionFlashMs: 1000,
enableInitRegionFlash: true,
}}
initialSession={sessionDraft ?? undefined}
showDebugPickers={false}
showToolbar={false}
showColorBar
showStatusRow={false}
showOverlayButtons
disabled={!isInteractive}
onWatch={(state, durationMs, detail) => {
setWatchState(state);
console.log('[onWatch]', state, durationMs, detail);
}}
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
toast(payload.hint);
return;
}
console.log('painted', payload.regionId, payload.regionName, payload.color, payload.configJson);
}}
onError={message => {
setErrorMessage(message);
setWatchState('error');
}}
/>
</View>
);
}
```
---
## 📊 状态变量
| 状态 | 类型 | 用途 |
| --- | --- | --- |
| `imagePaths` | `{ origin, mask } \| null` | 宿主解析后的本地/远程图片路径 |
| `pathsError` | `string` | 路径解析或 PNG 预热失败时的错误信息 |
| `watchState` | `MaskSegmentWatchState \| ''` | `onWatch` 上报的初始化阶段 |
| `isInteractive` | 派生值 | `interactive``mask_paths_ready` 时为 `true` — 允许操作 |
| `isOutlineReady` | 派生值 | `mask_paths_ready` 时为 `true` — 轮播虚线轮廓已就绪 |
| `isCanvasLoading` | 派生值 | Canvas 初始化阻塞中(不包括等待 PNG 路径) |
| `errorMessage` | `string` | `onError` 写入的分割/加载失败信息 |
| `sessionDraft` | `MaskSegmentSession \| null` | 从 MMKV 或类似存储恢复的草稿 |
---
## ⚙️ 选择配置值
| 配置 | 使用顶层 prop 的场景 | 使用嵌套 Config 的场景 |
| --- | --- | --- |
| 语义颜色 | `semanticColors={...}` 多数情况使用 | `maskConfig.semanticColors` 与其他遮罩参数配合使用时 |
| 轮廓颜色 | `regionOutlineColor="..."` 多数情况使用 | `paintConfig.regionOverlayFill` 同时自定义画笔调色板时 |
| 黑色阈值、最大区域数 | — | `maskConfig` |
| 图像处理尺寸 | — | `pipelineConfig` |
| 闪烁间隔、点击容差 | — | `interactionConfig` |
顶层 props 和嵌套 Configs **可以共存**;顶层 `semanticColors` / `regionOutlineColor` 优先级更高。
---
## 🔄 watchState 与 UI 引导
```ts
// 阻塞加载(区域和上色层就绪之前)
const isLoading = ![
'interactive',
'mask_paths_ready',
'error',
'',
].includes(watchState);
// 允许点击区域、选择颜色、上色(无需等待轮廓路径)
const canOperate =
watchState === 'interactive' || watchState === 'mask_paths_ready';
// 轮播虚线轮廓已完全就绪(可选 — 可关闭"轮廓准备中"提示)
const isOutlineReady = watchState === 'mask_paths_ready';
// 显示错误界面
const hasError = watchState === 'error';
```
`interactive` 状态下,`detail.maskPathsReady` 通常为 `false``mask_paths_ready` 状态下为 `true`。间隔约 100ms异步 Skia 路径构建),不阻塞点击上色。
`originUrl` / `maskUrl` 支持:
- 本地路径:`file:///...` 或绝对路径
- 远程 URL`http(s)://...`(组件内部处理下载和解码)
> 旧版 props `originImgPath` / `maskImgPath` 已弃用;请使用 `originUrl` / `maskUrl`

View File

@ -0,0 +1,17 @@
---
id: dependencies
title: 依赖项
---
# 📚 依赖项
| 包 | 用途 |
| --- | --- |
| `@shopify/react-native-skia` | Canvas 渲染、Path、虚线描边、Blend 合成 |
| `react-native-fast-opencv` | 遮罩形态学、轮廓处理 |
| `react-native-fs` | 图层缓存、PNG 保存 |
| `react-native-image-picker` | Demo 相册选择器(可选) |
| `react-native-reanimated` | Skia 动画依赖 |
| `react-native-safe-area-context` | 安全区域边距(可选) |
| `buffer` | 二进制 buffer polyfill |
| `upng-js` | 纯 JS PNG 编解码 |

View File

@ -0,0 +1,25 @@
---
id: custom-colors
title: 自定义语义颜色表
---
# 🎨 自定义语义颜色表
当遮罩使用不同的颜色值时,可以定义自定义语义颜色:
```tsx
const gymColors: MaskSemanticColor[] = [
{ name: 'wall', hex: '#4363D8', bgr: { b: 216, g: 99, r: 67 } },
{ name: 'ceiling', hex: '#3CB44B', bgr: { b: 75, g: 180, r: 60 } },
// ...
];
<MaskSegmentCanvas
semanticColors={gymColors}
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
/>
```
:::caution
`semanticColors` 必须与后端/标注遮罩中使用的语义颜色匹配;不匹配会导致识别偏差。
:::

View File

@ -0,0 +1,18 @@
---
id: draft-recovery
title: 草稿恢复
---
# 💾 草稿恢复
```tsx
const draft = JSON.parse(mmkv.getString('paint_draft'));
<MaskSegmentCanvas
originUrl={draft.originUrl}
maskUrl={draft.maskUrl}
initialSession={draft}
/>
```
使用 `ref.session()` 导出当前会话并存储在 MMKV 或 AsyncStorage 中,以便后续恢复。

View File

@ -0,0 +1,21 @@
---
id: local-paths
title: 从 API 传入本地路径
---
# 🌐 从 API 传入本地路径
```tsx
<MaskSegmentCanvas
originUrl={localOriginPath}
maskUrl={localMaskPath}
showDebugPickers={false}
showToolbar={false}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="#1e96ff"
onWatch={(state, ms, detail) => {
if (state === 'interactive') hideBlockingLoader();
if (state === 'mask_paths_ready') hideOutlineHint();
}}
/>
```

View File

@ -0,0 +1,19 @@
---
id: png-pre-warm
title: PNG 缓存预热(推荐)
---
# 🔥 PNG 缓存预热(推荐)
在挂载 Canvas 前预热 PNG 解码缓存,可节省 **100250ms** 的初始化时间。
```tsx
import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
async function openPaintScreen(originUrl: string, maskUrl: string) {
await prewarmPngBgrCacheAsync([originUrl, maskUrl]);
navigation.navigate('Paint', { originUrl, maskUrl });
}
```
在下载/提取图片后、导航到上色界面前调用 `prewarmPngBgrCacheAsync` 可获得最佳性能。

View File

@ -0,0 +1,107 @@
---
id: installation
title: 安装
---
# 📦 安装
## Peer 依赖
在宿主项目中安装以下依赖(版本应与宿主 RN 版本匹配):
```bash
npm install @shopify/react-native-skia react-native-reanimated react-native-fast-opencv react-native-fs buffer upng-js react-native-gesture-handler
# 如果使用 showDebugPickers相册选择器
npm install react-native-image-picker
# 安全区域边距
npm install react-native-safe-area-context
```
## 安装后设置
本库依赖 `patch-package` 来修补 `react-native-fast-opencv`。宿主项目的 `package.json` 必须包含:
```json
{
"scripts": {
"postinstall": "patch-package"
},
"devDependencies": {
"patch-package": "^8.0.1"
}
}
```
安装本库后,`node_modules/react-native-mask-segment-canvas/patches/` 中的补丁将在宿主 `postinstall` 期间自动应用。
## iOS / Android 原生依赖
```bash
cd ios && pod install && cd ..
```
确保宿主项目已按照各库文档完成 Skia、Reanimated 和 OpenCV 的原生设置。
## Metro 配置
使用 `npm link`、monorepo 或 `file:` 依赖时,请将本库添加到 `watchFolders`,并使用 `extraNodeModules` + `blockList` 防止重复模块解析:
```js
const path = require('path');
module.exports = {
watchFolders: [path.resolve(__dirname, '../MaskSegmentApp')],
resolver: {
nodeModulesPaths: [path.resolve(__dirname, 'node_modules')],
extraNodeModules: {
'react-native-reanimated': path.resolve(__dirname, 'node_modules/react-native-reanimated'),
'@shopify/react-native-skia': path.resolve(__dirname, 'node_modules/@shopify/react-native-skia'),
'react-native-gesture-handler': path.resolve(__dirname, 'node_modules/react-native-gesture-handler'),
'react-native-fast-opencv': path.resolve(__dirname, 'node_modules/react-native-fast-opencv'),
'react-native-safe-area-context': path.resolve(__dirname, 'node_modules/react-native-safe-area-context'),
'react-native-fs': path.resolve(__dirname, 'node_modules/react-native-fs'),
},
blockList: [
/\/MaskSegmentApp\/node_modules\/@shopify\/react-native-skia\//,
/\/MaskSegmentApp\/node_modules\/react-native-reanimated\//,
/\/MaskSegmentApp\/node_modules\/react-native-fast-opencv\//,
/\/MaskSegmentApp\/node_modules\/react-native-gesture-handler\//,
/\/MaskSegmentApp\/node_modules\/react-native-safe-area-context\//,
/\/MaskSegmentApp\/node_modules\/react-native-fs\//,
],
},
};
```
**强烈推荐** — 在宿主 `index.js` 最顶部(任何业务代码之前)添加:
```js
import '@shopify/react-native-skia';
```
完整的配置(含所有 peer singleton 包)请参考 `example/metro.config.js``example/index.js`
## 故障排除:重复模块错误
常见症状:
- `SkiaPictureView must be a function (received 'undefined')`
- `createAnimatedNode: Animated node[...] already exists`
这些问题几乎都是由于 Metro 解析了多份 reanimated / skia / gesture-handler / fast-opencv / safe-area 包副本导致的。
**最佳实践:**
1. 从 `example/metro.config.js` 复制 `singletonPackages` + `extraNodeModules` + `blockList` 模式
2. 在 `index.js` 顶部按顺序导入 gesture-handler → reanimated → skia
3. 使用 `--reset-cache` 重启 Metro 并重新安装应用
详细清单和模板请参阅示例项目。
### 集成方式
| 方式 | 说明 |
| --- | --- |
| `npm install` | 生产环境推荐 |
| `npm link` | 本地开发 |
| `file:..` | 相对路径依赖 |

View File

@ -0,0 +1,37 @@
---
id: interaction-guide
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

@ -0,0 +1,52 @@
---
id: intro
title: 概述
---
# 🎨 react-native-mask-segment-canvas
一个 React Native **0.79** 交互式遮罩分割库。核心导出为 `MaskSegmentCanvas` 组件,可通过 **npm 包****npm link** 在任何 React Native 项目中使用。
- 🧠 **OpenCV** (`react-native-fast-opencv`):遮罩语义布局、踢脚线修补、区域提取
- 🖌️ **Skia RuntimeEffect (SkSL)**:单 Pass 全屏着色器,混合原图 + LAB 低频/高频纹理颜色叠加
- ✂️ **Skia Path**:区域虚线轮廓高亮
- 🧲 **磁性套索Magnetic Lasso**手动墙面分区支持边缘吸附Sobel 梯度 + Dijkstra 最短路径)和主动轮廓边界精炼
- 👆 **交互**:底部颜色条选择画笔(可选初始化)→ 点击区域上色;未选择画笔时点击会触发 `onPaintCallback` 并附带提示;未选画笔时长按可预览区域的虚线轮廓
本仓库同时作为 **库源码**`src/index.ts`)和 **自测 Demo**(根目录 `App.tsx`)。
📌 **推荐的集成 Demo 请查看 `example/` 目录** — 该目录仅使用公开 API完全模拟消费方项目的集成方式包括 `package.json`、Metro 配置和完整的参考 `App.tsx`)。
---
## 🔭 概述
`MaskSegmentCanvas` 渲染原始图像并叠加语义遮罩,允许用户点击区域并应用颜色。处理流程:
1. 📥 **加载**原始图像和遮罩图像(本地 `file://` 或远程 `http(s)://`
2. 🧩 **分割**通过 OpenCV 将遮罩分割为语义区域(墙面、天花板、踢脚线等)
3. 🎨 **准备**通过 SkSL 生成 LAB 频域层纹理,实现逼真的颜色混合
4. 📐 **构建**每个区域的 Skia 虚线轮廓路径
5. 🧲 **手动分割**(可选)— 在墙面上绘制套索多边形,将其细分为可独立上色的 `wall-N` 区域,可选边缘吸附和主动轮廓精炼
6. 👆 **交互** — 用户选择画笔颜色并点击区域上色;上色层保留底层纹理
7. 💾 **保存**合成结果为 PNG导出 JSON 会话用于草稿恢复
组件通过 `onWatch` 发出 Pipeline 状态转换,宿主应用可据此显示相应的加载状态。
---
## 📋 环境要求
- 🟢 Node.js >= 18推荐 20+
- 🍎 Xcode 15+iOS
- 🤖 Android Studio + JDK 17Android
- 📦 CocoaPodsiOS
---
## 🚀 下一步
- **[安装](/docs/installation)** — 在项目中配置库
- **[快速开始](/docs/quick-start)** — 运行开发 Demo
- **[基本用法](/docs/basic-usage)** — 入门最小示例
- **[API 参考](/docs/api)** — 完整的 Props 和方法文档

View File

@ -0,0 +1,11 @@
---
id: notes
title: 注意事项
---
# 📝 注意事项
- 遮罩图像应为与原始图像同尺寸的语义色块图(黑色背景 + 纯色区域)。`max(B,G,R) < blackThreshold`默认 30的像素将被排除在分割之外
- OpenCV 分割在 JS 线程上运行;非常大的图像可能导致掉帧。使用 `pipelineConfig.maxImageLongSide` 限制处理分辨率。
- iOS 相册访问需要照片权限(仅在启用 `showDebugPickers` 时需要)。
- `semanticColors` 必须与后端/标注遮罩中使用的语义颜色匹配;不匹配会导致识别偏差。

View File

@ -0,0 +1,92 @@
---
id: performance
title: 性能
---
# ⚡ 性能
以下数据基于 Demo 测试图片(`assets/test/origin.png` **1080×1920**6 个语义区域)、**默认 `pipelineConfig`** 和 `onWatch` `durationMs`(从 `init` 开始测量)。这些是 **经验范围数据**,非严格基准测试;实际设备结果因 CPU、存储和 RN 版本而异。
## 实测参考(开发环境 + PNG 预热)
Demo 在挂载 Canvas 前调用 `prewarmPngBgrCacheAsync([origin, mask])`,因此 PNG 解码命中内存缓存。典型日志:
| 阶段 | watchState | 大约耗时 | 备注 |
| --- | --- | --- | --- |
| 遮罩对齐 | `mask_aligned` | ~160ms | 遮罩缩放至分割工作分辨率 |
| 区域就绪 | `regions_ready` / `mask_sampled` | ~320ms | 布局扫描 + 踢脚线 + pickMap |
| **可交互** | `**interactive`** | **~320450ms** | 可点击区域、选择颜色、Shader 上色 |
| 轮廓就绪 | `mask_paths_ready` | ~430550ms | `interactive` 后约 100ms轮播轮廓可显示 |
`interactive` **不等待**轮廓路径;`mask_paths_ready` 仅影响初始轮播和可选的 UI 提示。
同图子步骤耗时大小(`__DEV__` 日志,默认 pipeline
| 子步骤 | 大约耗时 | 工作分辨率 |
| --- | --- | --- |
| OpenCV LAB 高/低频 | ~1040ms | 270×480 |
| 高/低频 Skia 纹理 | ~2030ms | 同上 |
| 布局扫描 + 踢脚线 + pick 表 | ~90120ms | 405×7201080p → longSide 720 |
| 全轮廓路径(异步,非阻塞) | ~80150ms | 270×480 |
## 分辨率与 pipelineConfig
计算密集型步骤受 **最大长边限制** 约束,**不随 4K/8K 原图线性增长**。**完整 PNG 解码**仍随像素数线性增长。
| 步骤 | 配置键 | 1080×1920 实际尺寸 | 随原图像素数增长 |
| --- | --- | --- | --- |
| PNG 解码 | — | 1080×1920 × 2 张图片 | **是** |
| 遮罩分割 / pickMap | `maxImageLongSide: 720` | ~405×720 | **否**(长边 >720 时固定) |
| Shader 高/低频 | `paintFreqMaxLongSide: 480` | ~270×480 | **否** |
| 工作区 Skia 原图 | 同 `maxImageLongSide` | ~405×720 | **否** |
| 虚线轮廓 | `maskPathMaxLongSide: 480` | ~270×480 | **否**(不阻塞 `interactive` |
## interactive 预估(默认 Pipeline
| 原始图像规格 | 相对于 1080p 像素 | PNG 预热后 | 冷启动(无预热) |
| --- | --- | --- | --- |
| 1080×1920 | 1× | **320450ms** | **450700ms** |
| 1440×25602K | ~1.8× | **400550ms** | **600900ms** |
| 3840×21604K | ~4× | **500750ms** | **8001200ms** |
| 7680×43208K | ~16× | **0.81.5s** | **1.53s+** |
> **`<300ms` interactive**1080p + 预热 + 默认 pipeline + 高端设备上可达,但属 **乐观估计** — 不应视为全设备 SLA。
## 设备等级1080p默认 Pipeline
相对于约 320ms 的开发环境基线:
| 等级 | 相对倍数 | 预热后 `interactive` | 冷启动 |
| --- | --- | --- | --- |
| 旗舰 iOS / 新款旗舰 Android | 0.81.2× | 300450ms | 500800ms |
| 中端 Android | 1.52.5× | 500800ms | 700ms1.2s |
| 低端 Android4GB旧 SoC | 2.54× | 800ms1.3s | 12s+ |
Android 额外开销主要来自JS ↔ OpenCV 桥接、内存带宽/GC、Skia 纹理上传。
## 提高 maxImageLongSide 的影响
`pipelineConfig.maxImageLongSide` 设为 **1280**(高于默认 720会使分割工作区变为约 720×1280像素数约为 720 档的 **3 倍**
| 场景 | 默认 720 | 提高到 1280 |
| --- | --- | --- |
| 1080p `interactive`(中端设备) | ~320800ms | **500ms1s+** |
| 分割 / pickMap 耗时 | ~90120ms | ~250350ms |
更高精度带来更长的初始化时间。要保持在 **`<500ms` interactive**,保留默认 **720**;必要时可降至 **640**
## 优化建议
1. 🚀 **PNG 预热(推荐)**:在下载/提取图片后、导航到上色界面前调用 `prewarmPngBgrCacheAsync`。通常可节省 **100250ms**(低端设备收益最大)。
```tsx
import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
await prewarmPngBgrCacheAsync([originPath, maskPath]);
// 然后挂载 MaskSegmentCanvas
```
2. ⏱️ **加载时机**:在 `interactive` 时关闭阻塞加载器;可选监听 `mask_paths_ready` 以显示"轮廓准备中"提示。
3. 🖼️ **大图 / 低端设备**:保持默认 `maxImageLongSide: 720`;可选将 `paintFreqMaxLongSide` 降至 **360**
4. 📷 **4K 素材**:在宿主侧先降采样再传入,或接受约 0.81.5s 的 `interactive`(含预热)。
5. 🔍 **可观测性**:观察 Metro 日志中的 `[MaskSegment]`、`[⏱ ...]` 前缀和 `onWatch` `durationMs`

View File

@ -0,0 +1,33 @@
---
id: project-structure
title: 项目结构
---
# 📁 项目结构
```
MaskSegmentApp/ # 仓库根目录npm 包 react-native-mask-segment-canvas
├── App.tsx # 开发自测 Demo直接从 ./src 导入)
├── src/
│ ├── index.ts # 包入口消费方import 'react-native-mask-segment-canvas'
│ ├── components/
│ │ ├── MaskSegmentCanvas.tsx
│ │ └── MaskSegmentCanvas.types.ts
│ └── utils/
│ ├── maskSegmentation.ts
│ ├── maskSegmentRuntime.ts
│ ├── maskSemanticPalette.ts
│ ├── magneticLasso.ts # 边缘吸附套索Sobel + Dijkstra
│ ├── activeContour.ts # 主动轮廓精炼Snake + Balloon
│ ├── wallTextureSplit.ts # 自动与手动墙面纹理分割
│ └── ...
├── example/ # ★ 推荐:消费方集成 Demo
│ ├── App.tsx # 仅使用公开 API 的完整示例
│ ├── index.js / app.json
│ ├── package.json # 所需依赖 + "react-native-mask-segment-canvas": "file:.."
│ ├── metro.config.js / babel.config.js / tsconfig.json
│ └── README.md # 如何在真实项目中集成
├── patches/ # 随包发布;由宿主 postinstall 应用
├── ios/ # 根 Demo 原生项目(不发布到 npm
└── android/
```

View File

@ -0,0 +1,25 @@
---
id: quick-start
title: 快速开始(开发 Demo
---
# 🚀 快速开始(开发 Demo
根目录 `App.tsx` 是一个完整的自测 Demo直接从 `./src` 导入。
```bash
cd MaskSegmentApp
npm install
cd ios && bundle exec pod install && cd ..
npm start
# 在另一个终端中
npm run ios
# 或
npm run android
```
**查看消费方项目如何集成:** 进入 `example/` 目录并按照其中的 `README.md` 操作。它使用 `import from 'react-native-mask-segment-canvas'` 配合标准 `package.json` 和 Metro 配置,完全模拟消费方环境。

View File

@ -0,0 +1,42 @@
---
id: troubleshooting
title: 故障排除
---
# 🔧 故障排除
## iOS pod install 失败
```bash
cd ios
bundle install
bundle exec pod install --repo-update
```
## Android 构建错误
```bash
cd android && ./gradlew clean && cd ..
```
## 分割失败 / 零区域
- 确认 `originUrl` / `maskUrl` 可访问
- 确认遮罩语义颜色与 `semanticColors` 配置匹配
- 检查 Metro 日志中的 `[MaskSegment]` / `[⏱ ...]` 输出
## 虚线轮廓错位 / 多余轮廓
- 轮廓从遮罩像素外部轮廓生成;长按仅显示触摸点下的连通分量
- 初始轮播仅显示每个语义区域的最大连通分量
## 常见重复模块错误
**症状:**
- `SkiaPictureView must be a function (received 'undefined')`
- `createAnimatedNode: Animated node[...] already exists`
**解决方案:**
1. 从 `example/metro.config.js` 复制 `singletonPackages` + `extraNodeModules` + `blockList` 模式
2. 在 `index.js` 顶部按顺序导入 gesture-handler → reanimated → skia
3. 使用 `--reset-cache` 重启 Metro 并重新安装应用

18283
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
docs/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "mask-segment-canvas-docs",
"version": "0.3.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"webpack": "^5.95.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.7.0",
"@docusaurus/tsconfig": "3.7.0",
"@docusaurus/types": "3.7.0",
"typescript": "~5.6.0"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=18.0"
}
}

85
docs/sidebars.ts Normal file
View File

@ -0,0 +1,85 @@
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
docsSidebar: [
{
type: 'doc',
id: 'intro',
label: 'Overview',
},
{
type: 'doc',
id: 'installation',
label: 'Installation',
},
{
type: 'doc',
id: 'quick-start',
label: 'Quick Start',
},
{
type: 'doc',
id: 'basic-usage',
label: 'Basic Usage',
},
{
type: 'category',
label: 'API Reference',
link: { type: 'doc', id: 'api/index' },
items: [
'api/props-image',
'api/props-semantic',
'api/mask-config',
'api/pipeline-config',
'api/paint-config',
'api/interaction-config',
'api/ui-controls',
'api/callbacks',
'api/ref-methods',
'api/storage',
],
},
{
type: 'doc',
id: 'interaction-guide',
label: 'Interaction Guide',
},
{
type: 'category',
label: 'Integration Examples',
items: [
'examples/png-pre-warm',
'examples/local-paths',
'examples/draft-recovery',
'examples/custom-colors',
],
},
{
type: 'doc',
id: 'project-structure',
label: 'Project Structure',
},
{
type: 'doc',
id: 'dependencies',
label: 'Dependencies',
},
{
type: 'doc',
id: 'performance',
label: 'Performance',
},
{
type: 'doc',
id: 'notes',
label: 'Notes',
},
{
type: 'doc',
id: 'troubleshooting',
label: 'Troubleshooting',
},
],
};
export default sidebars;

30
docs/src/css/custom.css Normal file
View File

@ -0,0 +1,30 @@
:root {
--ifm-color-primary: #1e78ff;
--ifm-color-primary-dark: #0167f4;
--ifm-color-primary-darker: #0161e6;
--ifm-color-primary-darkest: #004fbe;
--ifm-color-primary-light: #3a8aff;
--ifm-color-primary-lighter: #4893ff;
--ifm-color-primary-lightest: #72adff;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
[data-theme='dark'] {
--ifm-color-primary: #4d95ff;
--ifm-color-primary-dark: #2b80ff;
--ifm-color-primary-darker: #1a76ff;
--ifm-color-primary-darkest: #005de7;
--ifm-color-primary-light: #6faaff;
--ifm-color-primary-lighter: #80b4ff;
--ifm-color-primary-lightest: #b3d2ff;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.hero--primary {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
}
[data-theme='dark'] .hero--primary {
background: linear-gradient(135deg, #0a0a1a 0%, #0d1528 50%, #081d3a 100%);
}

View File

@ -0,0 +1,99 @@
.heroBanner {
padding: 4rem 0;
text-align: center;
position: relative;
overflow: hidden;
}
.heroTitle {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.heroSubtitle {
font-size: 1.5rem;
font-weight: 600;
margin-top: 0.5rem;
opacity: 0.9;
}
.heroDescription {
font-size: 1.1rem;
max-width: 640px;
margin: 1rem auto 0;
opacity: 0.75;
line-height: 1.7;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.features {
padding: 4rem 0;
background: var(--ifm-background-color);
}
.featureCard {
padding: 1.5rem;
text-align: center;
}
.featureEmoji {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.featureTitle {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.featureDesc {
font-size: 0.95rem;
color: var(--ifm-color-emphasis-700);
line-height: 1.6;
}
.quickStart {
padding: 3rem 0 4rem;
background: var(--ifm-color-emphasis-100);
text-align: center;
}
[data-theme='dark'] .quickStart {
background: var(--ifm-color-emphasis-0);
}
.sectionTitle {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
.codeBlock {
max-width: 640px;
margin: 0 auto;
text-align: left;
background: #1e1e2e;
border-radius: 8px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
}
.codeBlock pre {
margin: 0;
}
.codeBlock code {
color: #cdd6f4;
font-size: 0.92rem;
font-family: 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
}

140
docs/src/pages/index.tsx Normal file
View File

@ -0,0 +1,140 @@
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import styles from './index.module.css';
function HeroSection() {
return (
<header className={clsx('hero hero--primary', styles.heroBanner)}>
<div className="container">
<Heading as="h1" className={styles.heroTitle}>
🎨 MaskSegmentCanvas
</Heading>
<p className={styles.heroSubtitle}>
React Native Interactive Mask Segmentation
</p>
<p className={styles.heroDescription}>
OpenCV semantic segmentation + SkSL Shader coloring a powerful
interactive mask painting library for React Native 0.79+
</p>
<div className={styles.buttons}>
<Link className="button button--secondary button--lg" to="/docs/intro">
Get Started
</Link>
<Link className="button button--outline button--lg" to="/docs/api">
API Reference
</Link>
</div>
</div>
</header>
);
}
function FeatureCard({ emoji, title, description }: { emoji: string; title: string; description: string }) {
return (
<div className={clsx('col col--4', styles.featureCard)}>
<div className={styles.featureEmoji}>{emoji}</div>
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
<p className={styles.featureDesc}>{description}</p>
</div>
);
}
function FeaturesSection() {
const features = [
{
emoji: '🧠',
title: 'OpenCV Segmentation',
description:
'Semantic mask layout, baseboard patching, and region extraction powered by react-native-fast-opencv.',
},
{
emoji: '🖌️',
title: 'Skia SkSL Shader',
description:
'Single-pass full-screen shader blending original image + LAB low/high frequency texture color overlays.',
},
{
emoji: '👆',
title: 'Rich Interaction',
description:
'Bottom color bar, tap-to-paint, long-press preview, undo, compare with original, and draft recovery.',
},
{
emoji: '📐',
title: 'Skia Dash Outlines',
description:
'Dashed outline highlights for each semantic region with automatic carousel animation.',
},
{
emoji: '⚡',
title: 'High Performance',
description:
'~320450ms to interactive on 1080p images. Independent of origin resolution with pipeline capping.',
},
{
emoji: '🌐',
title: 'Remote & Local Images',
description:
'Supports file:// absolute paths and http(s):// remote URLs with built-in download and decode.',
},
];
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{features.map((feature, idx) => (
<FeatureCard key={idx} {...feature} />
))}
</div>
</div>
</section>
);
}
function QuickStartSection() {
return (
<section className={styles.quickStart}>
<div className="container">
<Heading as="h2" className={styles.sectionTitle}>Quick Start</Heading>
<div className={styles.codeBlock}>
<pre>
<code>{`npm install react-native-mask-segment-canvas
# Install peer dependencies
npm install @shopify/react-native-skia \\
react-native-reanimated \\
react-native-fast-opencv \\
react-native-fs buffer upng-js
# iOS
cd ios && pod install && cd ..`}</code>
</pre>
</div>
<div className={styles.buttons} style={{ marginTop: '1.5rem' }}>
<Link className="button button--primary button--lg" to="/docs/installation">
Full Installation Guide
</Link>
</div>
</div>
</section>
);
}
export default function Home(): JSX.Element {
const { siteConfig } = useDocusaurusContext();
return (
<Layout title={`${siteConfig.title} - React Native Mask Segmentation`} description="React Native interactive mask segmentation library with OpenCV semantic segmentation and Skia SkSL Shader coloring.">
<HeroSection />
<main>
<FeaturesSection />
<QuickStartSection />
</main>
</Layout>
);
}

6
docs/static/img/logo.svg vendored Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<rect width="100" height="100" rx="20" fill="#1e78ff"/>
<path d="M25 65 L40 70 L50 55 L60 40 L75 50 L75 65 L25 65Z" fill="white" opacity="0.9"/>
<path d="M25 65 L25 35 L50 55 L50 70Z" fill="white" opacity="0.6"/>
<path d="M50 55 L75 50 L60 40 L50 55Z" fill="white" opacity="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

6
docs/tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
}

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,15 +16,88 @@ function maskCfg() {
return getMaskSegmentRuntimeConfig().mask;
}
function bboxToPolygon(bbox: SegmentRegion['bbox']): { x: number; y: number }[] {
return [
{ x: bbox.x, y: bbox.y },
{ x: bbox.x + bbox.w, y: bbox.y },
{ x: bbox.x + bbox.w, y: bbox.y + bbox.h },
{ x: bbox.x, y: bbox.y + bbox.h },
type Point = { x: number; y: number };
/** Moore-neighbor boundary tracer on a binary mask component. */
function traceMaskPolygon(
mask: Uint8Array,
cols: number,
rows: number,
): Point[] {
// Find first non-zero pixel (top-left)
let startX = -1, startY = -1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (mask[y * cols + x]) { startX = x; startY = y; break; }
}
if (startX >= 0) break;
}
if (startX < 0) return [];
// Moore 8-neighbor clockwise trace
const dirs: [number, number][] = [
[1, 0], [1, -1], [0, -1], [-1, -1],
[-1, 0], [-1, 1], [0, 1], [1, 1],
];
const path: Point[] = [];
let cx = startX, cy = startY;
let dir = 7; // start searching from up-left
for (let i = 0; i < cols * rows; i++) {
path.push({ x: cx, y: cy });
let found = false;
for (let j = 0; j < 8; j++) {
const d = (dir + 1 + j) % 8; // search clockwise from last direction+1
const nx = cx + dirs[d][0];
const ny = cy + dirs[d][1];
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
if (mask[ny * cols + nx]) {
cx = nx; cy = ny; dir = (d + 4) % 8; // face back toward previous pixel
found = true;
break;
}
}
if (!found) break;
if (path.length > 2 && cx === startX && cy === startY) break;
}
return path;
}
/** Douglas-Peucker polygon simplification (epsilon in pixels). */
function simplifyPolygon(points: Point[], epsilon: number): Point[] {
if (points.length <= 2) return [...points];
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const recurse = (s: number, e: number) => {
if (e - s <= 1) return;
const dx = points[e].x - points[s].x;
const dy = points[e].y - points[s].y;
const lenSq = dx * dx + dy * dy;
let maxDist = 0, maxIdx = s;
for (let i = s + 1; i < e; i++) {
let d: number;
if (lenSq === 0) {
d = Math.hypot(points[i].x - points[s].x, points[i].y - points[s].y);
} else {
const t = Math.max(0, Math.min(1,
((points[i].x - points[s].x) * dx + (points[i].y - points[s].y) * dy) / lenSq,
));
d = Math.hypot(points[i].x - (points[s].x + t * dx), points[i].y - (points[s].y + t * dy));
}
if (d > maxDist) { maxDist = d; maxIdx = i; }
}
if (maxDist > epsilon) { keep[maxIdx] = 1; recurse(s, maxIdx); recurse(maxIdx, e); }
};
recurse(0, points.length - 1);
return points.filter((_, i) => keep[i]);
}
const POLYGON_SIMPLIFY_EPSILON = 2.5;
function computeLabChromaMaps(
originBgr: Uint8Array,
cols: number,
@ -42,6 +115,79 @@ function computeLabChromaMaps(
return { aMap, bMap };
}
/** Per-channel BGR Sobel gradient magnitude. max(B, G, R) for sensitivity to color edges. */
function buildEdgeBarrierMask(
bgr: Uint8Array,
cols: number,
rows: number,
wallIdx: number,
labels: Uint8Array,
baseboardBinary: Uint8Array,
threshold: number,
): Uint8Array {
const n = cols * rows;
const barriers = new Uint8Array(n);
if (threshold <= 0) return barriers;
// Per-channel Sobel 3x3 → take max raw gradient.
// Raw range is [0, ~1442] for 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,
};
});