Compare commits
4 Commits
8bc66a4ee9
...
3a3f07628d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3f07628d | ||
|
|
26d3717169 | ||
|
|
bea6de3767 | ||
|
|
f7fd70005d |
42
.github/workflows/deploy-docs.yml
vendored
Normal file
42
.github/workflows/deploy-docs.yml
vendored
Normal 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
|
||||||
1074
README.zh-CN.md
Normal file
1074
README.zh-CN.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,14 @@ import {
|
|||||||
setMaskSegmentRuntimeConfig,
|
setMaskSegmentRuntimeConfig,
|
||||||
} from '../src/utils/maskSegmentRuntime';
|
} from '../src/utils/maskSegmentRuntime';
|
||||||
import type { SegmentMaskResult } from '../src/utils/maskSegmentation';
|
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 WALL_IDX = 3;
|
||||||
const IGNORE = 255;
|
const IGNORE = 255;
|
||||||
@ -180,6 +187,196 @@ test('same-color wall with lighting gradient stays one region', () => {
|
|||||||
expect(wallSubs[0].name).toBe('wall-1');
|
expect(wallSubs[0].name).toBe('wall-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual wall split pick map keeps ceiling and wall sub-regions paintable', () => {
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 10;
|
||||||
|
const CEILING_IDX = 1;
|
||||||
|
const WALL_IDX = 3;
|
||||||
|
const pixelCount = cols * rows;
|
||||||
|
const labels = new Uint8Array(pixelCount);
|
||||||
|
labels.fill(CEILING_IDX);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
labels[y * cols + x] = WALL_IDX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseboardBinary = new Uint8Array(pixelCount);
|
||||||
|
const wallSubLabels = new Uint8Array(pixelCount);
|
||||||
|
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols / 2; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 0;
|
||||||
|
}
|
||||||
|
for (let x = cols / 2; x < cols; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexToName = [
|
||||||
|
'door',
|
||||||
|
'ceiling',
|
||||||
|
'cabinet',
|
||||||
|
'wall',
|
||||||
|
'baseboard',
|
||||||
|
'windowFrame',
|
||||||
|
'garageDoor',
|
||||||
|
'roof',
|
||||||
|
'eave',
|
||||||
|
];
|
||||||
|
const mergedRegions = [
|
||||||
|
{ id: 0, name: 'ceiling', area: 100 },
|
||||||
|
{ id: 1, name: 'wall-1', area: 50 },
|
||||||
|
{ id: 2, name: 'wall-2', area: 50 },
|
||||||
|
];
|
||||||
|
const nameToId = new Map(mergedRegions.map(r => [r.name, r.id]));
|
||||||
|
const pickRaw = buildPickMapAfterWallSplit(
|
||||||
|
labels,
|
||||||
|
baseboardBinary,
|
||||||
|
WALL_IDX,
|
||||||
|
wallSubLabels,
|
||||||
|
indexToName,
|
||||||
|
nameToId,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
const pick = dilatePickBuffer1px(pickRaw, cols, rows);
|
||||||
|
|
||||||
|
expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0);
|
||||||
|
expect(pickRegionIdAt(pick, cols, 3, 7)).toBe(1);
|
||||||
|
expect(pickRegionIdAt(pick, cols, 15, 7)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual wall split keeps non-wall region IDs stable', () => {
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 10;
|
||||||
|
const CEILING_IDX = 1;
|
||||||
|
const WALL_IDX = 3;
|
||||||
|
const WINDOW_IDX = 5;
|
||||||
|
const pixelCount = cols * rows;
|
||||||
|
const labels = new Uint8Array(pixelCount);
|
||||||
|
labels.fill(CEILING_IDX);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
labels[y * cols + x] = WALL_IDX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels[2 * cols + 18] = WINDOW_IDX;
|
||||||
|
|
||||||
|
const nonWallRegions = [
|
||||||
|
{ id: 0, name: 'ceiling', area: 100 },
|
||||||
|
{ id: 2, name: 'windowFrame', area: 20 },
|
||||||
|
];
|
||||||
|
const maxNonWallId = Math.max(...nonWallRegions.map(r => r.id));
|
||||||
|
const wallSubRegions = [
|
||||||
|
{ id: maxNonWallId + 1, name: 'wall-1', area: 50 },
|
||||||
|
{ id: maxNonWallId + 2, name: 'wall-2', area: 40 },
|
||||||
|
];
|
||||||
|
const mergedRegions = [...nonWallRegions, ...wallSubRegions];
|
||||||
|
|
||||||
|
expect(mergedRegions.find(r => r.name === 'ceiling')?.id).toBe(0);
|
||||||
|
expect(mergedRegions.find(r => r.name === 'windowFrame')?.id).toBe(2);
|
||||||
|
expect(mergedRegions.find(r => r.name === 'wall-1')?.id).toBe(3);
|
||||||
|
expect(mergedRegions.find(r => r.name === 'wall-2')?.id).toBe(4);
|
||||||
|
|
||||||
|
const indexToName = [
|
||||||
|
'door',
|
||||||
|
'ceiling',
|
||||||
|
'cabinet',
|
||||||
|
'wall',
|
||||||
|
'baseboard',
|
||||||
|
'windowFrame',
|
||||||
|
'garageDoor',
|
||||||
|
'roof',
|
||||||
|
'eave',
|
||||||
|
];
|
||||||
|
const baseboardBinary = new Uint8Array(pixelCount);
|
||||||
|
const wallSubLabels = new Uint8Array(pixelCount);
|
||||||
|
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols / 2; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 0;
|
||||||
|
}
|
||||||
|
for (let x = cols / 2; x < cols; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nameToId = new Map(mergedRegions.map(r => [r.name, r.id]));
|
||||||
|
const pickRaw = buildPickMapAfterWallSplit(
|
||||||
|
labels,
|
||||||
|
baseboardBinary,
|
||||||
|
WALL_IDX,
|
||||||
|
wallSubLabels,
|
||||||
|
indexToName,
|
||||||
|
nameToId,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
const pick = dilatePickBuffer1px(pickRaw, cols, rows);
|
||||||
|
expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0);
|
||||||
|
expect(pickRegionIdAt(pick, cols, 18, 2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('patchPickMapForManualWallSplit preserves non-wall pick codes', () => {
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 10;
|
||||||
|
const CEILING_IDX = 1;
|
||||||
|
const WALL_IDX = 3;
|
||||||
|
const pixelCount = cols * rows;
|
||||||
|
const labels = new Uint8Array(pixelCount);
|
||||||
|
labels.fill(CEILING_IDX);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
labels[y * cols + x] = WALL_IDX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPick = new Uint8Array(pixelCount);
|
||||||
|
existingPick.fill(0);
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
existingPick[y * cols + x] = 1; // ceiling id 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
existingPick[y * cols + x] = 2; // old wall id 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallSubLabels = new Uint8Array(pixelCount);
|
||||||
|
wallSubLabels.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
for (let y = 5; y < rows; y++) {
|
||||||
|
for (let x = 0; x < cols / 2; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 0;
|
||||||
|
}
|
||||||
|
for (let x = cols / 2; x < cols; x++) {
|
||||||
|
wallSubLabels[y * cols + x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseboardBinary = new Uint8Array(pixelCount);
|
||||||
|
const nameToId = new Map([
|
||||||
|
['ceiling', 0],
|
||||||
|
['wall-1', 3],
|
||||||
|
['wall-2', 4],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const patched = patchPickMapForManualWallSplit(
|
||||||
|
existingPick,
|
||||||
|
labels,
|
||||||
|
baseboardBinary,
|
||||||
|
WALL_IDX,
|
||||||
|
wallSubLabels,
|
||||||
|
nameToId,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pickRegionIdAt(patched, cols, 10, 2)).toBe(0);
|
||||||
|
expect(pickRegionIdAt(patched, cols, 3, 7)).toBe(3);
|
||||||
|
expect(pickRegionIdAt(patched, cols, 15, 7)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
test('single-texture wall becomes wall-1 when splitWalls enabled', () => {
|
test('single-texture wall becomes wall-1 when splitWalls enabled', () => {
|
||||||
const cols = 16;
|
const cols = 16;
|
||||||
const rows = 8;
|
const rows = 8;
|
||||||
@ -196,3 +393,82 @@ test('single-texture wall becomes wall-1 when splitWalls enabled', () => {
|
|||||||
expect(wallSubs).toHaveLength(1);
|
expect(wallSubs).toHaveLength(1);
|
||||||
expect(wallSubs[0].name).toBe('wall-1');
|
expect(wallSubs[0].name).toBe('wall-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('absorbSmallWallGapsForLassoPolygons merges thin unassigned wall slivers', () => {
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 1;
|
||||||
|
const pixelCount = cols * rows;
|
||||||
|
const labels = new Uint8Array(pixelCount);
|
||||||
|
labels.fill(WALL_IDX);
|
||||||
|
const baseboardBinary = new Uint8Array(pixelCount);
|
||||||
|
const priorAssigned = new Uint8Array(pixelCount);
|
||||||
|
priorAssigned.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
|
||||||
|
const polyLabels = new Uint8Array(pixelCount);
|
||||||
|
polyLabels.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
for (let x = 0; x <= 14; x++) {
|
||||||
|
polyLabels[x] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areas = [15];
|
||||||
|
const bboxes = [{ x: 0, y: 0, w: 15, h: 1 }];
|
||||||
|
|
||||||
|
absorbSmallWallGapsForLassoPolygons(
|
||||||
|
polyLabels,
|
||||||
|
1,
|
||||||
|
areas,
|
||||||
|
bboxes,
|
||||||
|
labels,
|
||||||
|
baseboardBinary,
|
||||||
|
WALL_IDX,
|
||||||
|
priorAssigned,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(polyLabels[15]).toBe(0);
|
||||||
|
expect(polyLabels[16]).toBe(0);
|
||||||
|
expect(polyLabels[17]).toBe(0);
|
||||||
|
expect(areas[0]).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absorbSmallWallGapsForLassoPolygons respects dilation radius limit', () => {
|
||||||
|
const cols = 20;
|
||||||
|
const rows = 1;
|
||||||
|
const pixelCount = cols * rows;
|
||||||
|
const labels = new Uint8Array(pixelCount);
|
||||||
|
labels.fill(WALL_IDX);
|
||||||
|
const baseboardBinary = new Uint8Array(pixelCount);
|
||||||
|
const priorAssigned = new Uint8Array(pixelCount);
|
||||||
|
priorAssigned.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
|
||||||
|
const polyLabels = new Uint8Array(pixelCount);
|
||||||
|
polyLabels.fill(WALL_SUB_LABEL_NONE);
|
||||||
|
for (let x = 0; x <= 10; x++) {
|
||||||
|
polyLabels[x] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areas = [11];
|
||||||
|
const bboxes = [{ x: 0, y: 0, w: 11, h: 1 }];
|
||||||
|
|
||||||
|
absorbSmallWallGapsForLassoPolygons(
|
||||||
|
polyLabels,
|
||||||
|
1,
|
||||||
|
areas,
|
||||||
|
bboxes,
|
||||||
|
labels,
|
||||||
|
baseboardBinary,
|
||||||
|
WALL_IDX,
|
||||||
|
priorAssigned,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(polyLabels[11]).toBe(0);
|
||||||
|
expect(polyLabels[12]).toBe(0);
|
||||||
|
expect(polyLabels[13]).toBe(0);
|
||||||
|
expect(polyLabels[14]).toBe(WALL_SUB_LABEL_NONE);
|
||||||
|
expect(areas[0]).toBe(14);
|
||||||
|
});
|
||||||
|
|||||||
2
dist/components/MaskSegmentCanvas.js
vendored
2
dist/components/MaskSegmentCanvas.js
vendored
File diff suppressed because one or more lines are too long
67
dist/components/MaskSegmentCanvas.types.d.ts
vendored
67
dist/components/MaskSegmentCanvas.types.d.ts
vendored
@ -53,6 +53,33 @@ export type MaskSegmentConfig = {
|
|||||||
splitWallsChromaBlurRadius?: number;
|
splitWallsChromaBlurRadius?: number;
|
||||||
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
||||||
splitWallsNeutralChromaMax?: number;
|
splitWallsNeutralChromaMax?: number;
|
||||||
|
/**
|
||||||
|
* Wall split: raw per-channel BGR Sobel gradient threshold for edge barriers
|
||||||
|
* (0 = disabled). Uses un-normalized 8‑bit Sobel magnitude on each B/G/R
|
||||||
|
* channel (max per pixel, range ≈ 0–1442). Visible wall seams ≈ 120–280,
|
||||||
|
* subtle lighting gradients ≈ 20–80. Default: 160.
|
||||||
|
*/
|
||||||
|
splitWallsEdgeBarrierThreshold?: number;
|
||||||
|
/**
|
||||||
|
* Morphological close radius for wall mask holes before component labeling.
|
||||||
|
* Non-wall pixels inside the wall boundary (windows, doors, mask artefacts)
|
||||||
|
* are temporarily filled so the BFS can bridge across them. Default: 3.
|
||||||
|
* Set to 0 to disable.
|
||||||
|
*/
|
||||||
|
splitWallsCloseMaskRadius?: number;
|
||||||
|
/** When true, disables automatic texture-based wall splitting (splitWalls). Manual lasso partitioning is used instead. */
|
||||||
|
manualSplitWalls?: boolean;
|
||||||
|
/** wall mask only, max number of manual wall sub-regions defined by lasso */
|
||||||
|
manualSplitWallsMaxCount?: number;
|
||||||
|
/**
|
||||||
|
* Manual lasso: morphological dilation radius (seg pixels) used to merge thin
|
||||||
|
* unassigned wall pockets adjacent to the drawn polygon.
|
||||||
|
*/
|
||||||
|
manualSplitWallsGapAbsorbDilatePx?: number;
|
||||||
|
/** When true, lasso mode uses edge-snapping (Sobel gradient + Dijkstra shortest-path). Default: false. */
|
||||||
|
magneticLasso?: boolean;
|
||||||
|
/** After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges. Default: false. */
|
||||||
|
activeContourRefine?: boolean;
|
||||||
};
|
};
|
||||||
export type PaintConfig = {
|
export type PaintConfig = {
|
||||||
palette?: BgrColor[];
|
palette?: BgrColor[];
|
||||||
@ -117,6 +144,32 @@ export type PaintBrushRequiredPayload = {
|
|||||||
regionName: string;
|
regionName: string;
|
||||||
};
|
};
|
||||||
export type PaintCallbackPayload = PaintSuccessPayload | 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 = {
|
export type MaskSegmentCanvasRef = {
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
swap: (showOrigin?: boolean) => void;
|
swap: (showOrigin?: boolean) => void;
|
||||||
@ -133,6 +186,20 @@ export type MaskSegmentCanvasRef = {
|
|||||||
getPaintedRegions: () => PaintedRegionRecord[];
|
getPaintedRegions: () => PaintedRegionRecord[];
|
||||||
/** Returns the most recent auto-export or save() result, if any. */
|
/** Returns the most recent auto-export or save() result, if any. */
|
||||||
getLastExport?: () => SavePaintResult | null;
|
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 = {
|
export type MaskSegmentCanvasProps = {
|
||||||
originUrl?: string;
|
originUrl?: string;
|
||||||
|
|||||||
2
dist/components/MaskSegmentCanvas.types.js
vendored
2
dist/components/MaskSegmentCanvas.types.js
vendored
@ -1 +1 @@
|
|||||||
"use strict";var r=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of s(e))!l.call(n,i)&&i!==t&&r(n,i,{get:()=>e[i],enumerable:!(o=a(e,i))||o.enumerable});return n};var m=n=>g(r({},"__esModule",{value:!0}),n);var u={};module.exports=m(u);
|
"use strict";var r=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var m=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of s(e))!l.call(n,i)&&i!==t&&r(n,i,{get:()=>e[i],enumerable:!(o=a(e,i))||o.enumerable});return n};var g=n=>m(r({},"__esModule",{value:!0}),n);var u={};module.exports=g(u);
|
||||||
|
|||||||
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
@ -1,5 +1,5 @@
|
|||||||
export { default } from './components/MaskSegmentCanvas';
|
export { 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 { 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 { 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';
|
export { prewarmPngBgrCache, prewarmPngBgrCacheAsync, } from './utils/pngImage';
|
||||||
|
|||||||
2
dist/index.js
vendored
2
dist/index.js
vendored
@ -1 +1 @@
|
|||||||
"use strict";var m=Object.create;var i=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var M=Object.getPrototypeOf,f=Object.prototype.hasOwnProperty;var p=(a,n)=>{for(var t in n)i(a,t,{get:n[t],enumerable:!0})},g=(a,n,t,P)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of I(n))!f.call(a,o)&&o!==t&&i(a,o,{get:()=>n[o],enumerable:!(P=E(n,o))||P.enumerable});return a};var _=(a,n,t)=>(t=a!=null?m(M(a)):{},g(n||!a||!a.__esModule?i(t,"default",{value:a,enumerable:!0}):t,a)),l=a=>g(i({},"__esModule",{value:!0}),a);var A={};p(A,{BASEBOARD_SEMANTIC_NAME:()=>r.BASEBOARD_SEMANTIC_NAME,DEFAULT_INTERACTION_CONFIG:()=>e.DEFAULT_INTERACTION_CONFIG,DEFAULT_MASK_CONFIG:()=>e.DEFAULT_MASK_CONFIG,DEFAULT_PAINT_CONFIG:()=>e.DEFAULT_PAINT_CONFIG,DEFAULT_PIPELINE_CONFIG:()=>e.DEFAULT_PIPELINE_CONFIG,MASK_SEMANTIC_COLORS:()=>r.MASK_SEMANTIC_COLORS,PIPELINE_HIGH:()=>e.PIPELINE_HIGH,PIPELINE_LOW:()=>e.PIPELINE_LOW,PIPELINE_MEDIUM:()=>e.PIPELINE_MEDIUM,PIPELINE_PRESETS:()=>e.PIPELINE_PRESETS,createRuntimeConfig:()=>e.createRuntimeConfig,default:()=>C.default,getMaskSegmentRuntimeConfig:()=>e.getMaskSegmentRuntimeConfig,prewarmPngBgrCache:()=>s.prewarmPngBgrCache,prewarmPngBgrCacheAsync:()=>s.prewarmPngBgrCacheAsync,resolveAssetPath:()=>S.resolveAssetPath,resolvePipelineConfig:()=>e.resolvePipelineConfig,setMaskSegmentRuntimeConfig:()=>e.setMaskSegmentRuntimeConfig});module.exports=l(A);var C=_(require("./components/MaskSegmentCanvas")),r=require("./utils/maskSemanticPalette"),e=require("./utils/maskSegmentRuntime"),s=require("./utils/pngImage"),S=require("./utils/resolveAssetPath");
|
"use strict";var m=Object.create;var i=Object.defineProperty;var E=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,M=Object.prototype.hasOwnProperty;var f=(a,n)=>{for(var t in n)i(a,t,{get:n[t],enumerable:!0})},g=(a,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of I(n))!M.call(a,o)&&o!==t&&i(a,o,{get:()=>n[o],enumerable:!(r=E(n,o))||r.enumerable});return a};var p=(a,n,t)=>(t=a!=null?m(l(a)):{},g(n||!a||!a.__esModule?i(t,"default",{value:a,enumerable:!0}):t,a)),_=a=>g(i({},"__esModule",{value:!0}),a);var A={};f(A,{BASEBOARD_SEMANTIC_NAME:()=>s.BASEBOARD_SEMANTIC_NAME,DEFAULT_INTERACTION_CONFIG:()=>e.DEFAULT_INTERACTION_CONFIG,DEFAULT_MASK_CONFIG:()=>e.DEFAULT_MASK_CONFIG,DEFAULT_PAINT_CONFIG:()=>e.DEFAULT_PAINT_CONFIG,DEFAULT_PIPELINE_CONFIG:()=>e.DEFAULT_PIPELINE_CONFIG,MASK_SEMANTIC_COLORS:()=>s.MASK_SEMANTIC_COLORS,PIPELINE_HIGH:()=>e.PIPELINE_HIGH,PIPELINE_LOW:()=>e.PIPELINE_LOW,PIPELINE_MEDIUM:()=>e.PIPELINE_MEDIUM,PIPELINE_PRESETS:()=>e.PIPELINE_PRESETS,createRuntimeConfig:()=>e.createRuntimeConfig,default:()=>C.default,getMaskSegmentRuntimeConfig:()=>e.getMaskSegmentRuntimeConfig,prewarmPngBgrCache:()=>P.prewarmPngBgrCache,prewarmPngBgrCacheAsync:()=>P.prewarmPngBgrCacheAsync,resolveAssetPath:()=>S.resolveAssetPath,resolvePipelineConfig:()=>e.resolvePipelineConfig,setMaskSegmentRuntimeConfig:()=>e.setMaskSegmentRuntimeConfig});module.exports=_(A);var C=p(require("./components/MaskSegmentCanvas")),s=require("./utils/maskSemanticPalette"),e=require("./utils/maskSegmentRuntime"),P=require("./utils/pngImage"),S=require("./utils/resolveAssetPath");
|
||||||
|
|||||||
46
dist/utils/activeContour.d.ts
vendored
Normal file
46
dist/utils/activeContour.d.ts
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Active Contour Model — greedy snake + balloon force.
|
||||||
|
*
|
||||||
|
* After the user finishes a lasso polygon, this module refines the boundary
|
||||||
|
* vertices outward toward the true wall-mask edge. Each vertex samples
|
||||||
|
* positions along its outward normal and picks the one with lowest energy.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Subdivide polygon to get evenly-spaced control points
|
||||||
|
* 2. For each iteration (3-5 rounds):
|
||||||
|
* a. Compute outward normal at each point
|
||||||
|
* b. Sample N positions along the normal (outward first, then inward)
|
||||||
|
* c. Score each position: E = E_edge + E_smooth
|
||||||
|
* d. Move vertex to min-energy position (constrained to wall mask)
|
||||||
|
* 3. Douglas-Peucker simplify
|
||||||
|
*/
|
||||||
|
import { type WallMaskSample } from './magneticLasso';
|
||||||
|
export type ActiveContourOpts = {
|
||||||
|
/** Number of greedy iterations (default 3). */
|
||||||
|
iterations?: number;
|
||||||
|
/** Number of sample positions along normal per direction (default 6). */
|
||||||
|
samplesPerDirection?: number;
|
||||||
|
/** Step size (norm coords) between samples (default 0.003). */
|
||||||
|
sampleStep?: number;
|
||||||
|
/** Smoothness weight — higher keeps vertices more uniformly spaced (default 0.15). */
|
||||||
|
smoothWeight?: number;
|
||||||
|
/** Edge weight — higher makes contour hug mask boundary (default 1.0). */
|
||||||
|
edgeWeight?: number;
|
||||||
|
/** Balloon bias — extra outward push per iteration (default 0.002). */
|
||||||
|
balloonForce?: number;
|
||||||
|
/** Minimum vertex count for a polygon to be refined (default 4). */
|
||||||
|
minVertices?: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Refine a single closed lasso polygon to hug the wall-mask outer boundary.
|
||||||
|
*
|
||||||
|
* Returns a new vertex list (not mutated in place). Returns the original
|
||||||
|
* polygon unchanged if it has too few vertices or no wall mask is given.
|
||||||
|
*/
|
||||||
|
export declare function refinePolygonToWallEdges(vertices: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[], mask: WallMaskSample, opts?: ActiveContourOpts): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[];
|
||||||
1
dist/utils/activeContour.js
vendored
Normal file
1
dist/utils/activeContour.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
"use strict";var W=Object.defineProperty;var q=Object.getOwnPropertyDescriptor;var E=Object.getOwnPropertyNames;var V=Object.prototype.hasOwnProperty;var B=(n,e)=>{for(var m in e)W(n,m,{get:e[m],enumerable:!0})},N=(n,e,m,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of E(e))!V.call(n,t)&&t!==m&&W(n,t,{get:()=>e[t],enumerable:!(o=q(e,t))||o.enumerable});return n};var U=n=>N(W({},"__esModule",{value:!0}),n);var J={};B(J,{refinePolygonToWallEdges:()=>G});module.exports=U(J);var T=require("./magneticLasso");const L={iterations:3,samplesPerDirection:6,sampleStep:.003,smoothWeight:.15,edgeWeight:1,balloonForce:.002,minVertices:4};function _(n,e,m){const o=e.x-n.x,t=e.y-n.y,r=m.x-e.x,s=m.y-e.y,c=o+r,u=t+s,f=-u,h=c,l=u,x=-c,a=r*h-s*f,g=r*x-s*l,i=Math.hypot(f,h);if(i<1e-8)return{x:0,y:0};const[y,b]=a<g?[f,h]:[l,x];return{x:y/i,y:b/i}}function j(n,e,m,o){const{labels:t,baseboardBinary:r,cols:s,rows:c,wallSemanticIdx:u}=m;if(s<=0||c<=0)return o;const f=Math.round(n*s),h=Math.round(e*c),l=Math.ceil(o);let x=o+1;for(let a=-l;a<=l;a++)for(let g=-l;g<=l;g++){const i=f+g,y=h+a;if(i<0||y<0||i>=s||y>=c)continue;const b=y*s+i;if(!(t[b]!==u||r[b])&&(i===0||y===0||i===s-1||y===c-1||t[b-1]!==u||t[b+1]!==u||t[b-s]!==u||t[b+s]!==u)){const d=Math.hypot(g,a);d<x&&(x=d)}}return x/Math.max(s,c)}function O(n,e,m,o,t,r){if(!(0,T.isNormPointOnWallMask)(n,e,m))return 1e6;const s=j(n,e,m,o)/o;let c=0;if(t.length>=2){const u=t[0],f=t[t.length-1],h=(u.x+f.x)/2,l=(u.y+f.y)/2;c=Math.hypot(n-h,e-l)}return r.edgeWeight*s+r.smoothWeight*c}function z(n,e){if(n.length<2)return[...n];const m=[],o=n.length;for(let t=0;t<o;t++){const r=n[t],s=n[(t+1)%o];m.push({...r});const c=Math.hypot(s.x-r.x,s.y-r.y),u=Math.floor(c/e);if(u>1)for(let f=1;f<u;f++){const h=f/u;m.push({x:r.x+h*(s.x-r.x),y:r.y+h*(s.y-r.y)})}}return m}function G(n,e,m){const o={...L,...m};if(n.length<o.minVertices||!e)return[...n];let t=z(n,o.sampleStep*2);const r=o.samplesPerDirection*o.sampleStep*Math.max(e.cols,e.rows),s=o.samplesPerDirection*o.sampleStep*Math.max(e.cols,e.rows);for(let h=0;h<o.iterations;h++){const l=[],x=t.length;for(let a=0;a<x;a++){const g=t[(a-1+x)%x],i=t[a],y=t[(a+1)%x],b=_(g,i,y);if(b.x===0&&b.y===0){l.push({...i});continue}let d={...i},p=O(i.x,i.y,e,s,[g,y],o);const M=o.balloonForce*(h+1);for(let S=1;S<=o.samplesPerDirection;S++){const P=S*o.sampleStep,D=i.x+b.x*(P+M),w=i.y+b.y*(P+M),A=O(D,w,e,s,[l.length>0?l[l.length-1]:t[(a-1+x)%x],y],o);A<p&&(p=A,d={x:D,y:w});const I=i.x-b.x*P,C=i.y-b.y*P,F=O(I,C,e,s,[l.length>0?l[l.length-1]:t[(a-1+x)%x],y],o);F<p&&(p=F,d={x:I,y:C})}l.push(d)}t=l}const c=H(t,.002);if(c.length<3)return[...n];const u=c[0],f=c[c.length-1];return Math.hypot(u.x-f.x,u.y-f.y)>5e-4&&c.push({...u}),c}function H(n,e){if(n.length<=2)return[...n];const m=new Uint8Array(n.length);m[0]=1,m[n.length-1]=1;function o(r,s){if(s-r<=1)return;const c=n[r].x,u=n[r].y,f=n[s].x,h=n[s].y,l=f-c,x=h-u,a=l*l+x*x;let g=0,i=r;for(let y=r+1;y<s;y++){let b;if(a===0)b=Math.hypot(n[y].x-c,n[y].y-u);else{const d=Math.max(0,Math.min(1,((n[y].x-c)*l+(n[y].y-u)*x)/a)),p=c+d*l,M=u+d*x;b=Math.hypot(n[y].x-p,n[y].y-M)}b>g&&(g=b,i=y)}g>e&&(m[i]=1,o(r,i),o(i,s))}o(0,n.length-1);const t=[];for(let r=0;r<n.length;r++)if(m[r]){if(t.length>0){const s=t[t.length-1];if(Math.hypot(n[r].x-s.x,n[r].y-s.y)<.001)continue}t.push({x:n[r].x,y:n[r].y})}return t}
|
||||||
112
dist/utils/magneticLasso.d.ts
vendored
Normal file
112
dist/utils/magneticLasso.d.ts
vendored
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Magnetic Lasso — edge-snapping polygon placement for manual wall splitting.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. buildEnergyMap → grayscale + downsample + Sobel gradient → energy grid
|
||||||
|
* 2. findShortestPath → Dijkstra 8-connected on low-energy (edge) pixels
|
||||||
|
* 3. extractCornerPoints → Douglas-Peucker simplification on raw path
|
||||||
|
* 4. upscalePath → map energy-space coords back to original image coords
|
||||||
|
*/
|
||||||
|
export type EnergyMap = {
|
||||||
|
/** Float32Array per-pixel energy values [0…1]; low = edge, high = flat */
|
||||||
|
map: Float32Array;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
/** Downscale ratio: energyDim / sourceDim (≈ em.w / sourceCols) */
|
||||||
|
scale: number;
|
||||||
|
/** Optional 0/1 mask at energy resolution; 0 = blocked for pathfinding */
|
||||||
|
traversable?: Uint8Array;
|
||||||
|
};
|
||||||
|
/** Seg-resolution wall mask used to constrain lasso vertices. */
|
||||||
|
export type WallMaskSample = {
|
||||||
|
labels: Uint8Array;
|
||||||
|
baseboardBinary: Uint8Array;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
wallSemanticIdx: number;
|
||||||
|
};
|
||||||
|
/** True when norm coords fall on a wall semantic pixel (excludes baseboard). */
|
||||||
|
export declare function isNormPointOnWallMask(normX: number, normY: number, mask: WallMaskSample): boolean;
|
||||||
|
export declare function filterVerticesToWallMask<T extends {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}>(vertices: T[], mask: WallMaskSample): T[];
|
||||||
|
/**
|
||||||
|
* Snap a normalized point to the nearest wall-mask boundary pixel when the
|
||||||
|
* touch falls within `snapRadiusSegPx` (segmentation resolution) of the edge.
|
||||||
|
*/
|
||||||
|
export declare function snapNormPointToWallEdge(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Prefer wall-mask corner pixels (L-shaped outer boundary), then plain edge.
|
||||||
|
* Used when the user taps without dragging.
|
||||||
|
*/
|
||||||
|
export declare function snapNormPointToWallCornerOrEdge(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* During vertex drag: snap to corner/edge when near, otherwise keep interior
|
||||||
|
* wall points so the anchor can move freely on the wall mask.
|
||||||
|
*/
|
||||||
|
export declare function resolveLassoWallDragPoint(normX: number, normY: number, mask: WallMaskSample, snapRadiusSegPx?: number): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null;
|
||||||
|
export declare function buildWallAllowedMask(labels: Uint8Array, baseboardBinary: Uint8Array, wallSemanticIdx: number): Uint8Array | null;
|
||||||
|
/**
|
||||||
|
* Build per-pixel energy map from BGR buffer.
|
||||||
|
* 1. Convert to grayscale via luminance weights
|
||||||
|
* 2. Downsample so longest side ≤ targetMaxSide
|
||||||
|
* 3. Apply Sobel 3×3 → gradient magnitude G
|
||||||
|
* 4. Energy = 1 / (1 + G), clamped to [0, 1]
|
||||||
|
*/
|
||||||
|
export declare function buildEnergyMap(bgrBuffer: Uint8Array, cols: number, rows: number, targetMaxSide?: number, allowedMask?: Uint8Array | null): EnergyMap;
|
||||||
|
/**
|
||||||
|
* Dijkstra shortest-path on 8-connected grid.
|
||||||
|
* Cost at each pixel = energy[pixel] * COST_SCALE (integer).
|
||||||
|
* Diagonal steps cost √2 × the neighbour's energy.
|
||||||
|
*
|
||||||
|
* Returns ordered path [start, …, end] in energy-map pixel space.
|
||||||
|
*/
|
||||||
|
export declare function findShortestPath(energy: Float32Array, energyW: number, energyH: number, sx: number, sy: number, ex: number, ey: number, traversable?: Uint8Array | null): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[];
|
||||||
|
/**
|
||||||
|
* Douglas-Peucker simplification. Keeps points where the perpendicular
|
||||||
|
* distance from the line segment exceeds epsilon.
|
||||||
|
*
|
||||||
|
* After DP, also enforces a minimum distance between consecutive anchors
|
||||||
|
* to avoid overly dense clusters.
|
||||||
|
*/
|
||||||
|
export declare function extractCornerPoints(path: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[], minDistance?: number, epsilon?: number): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[];
|
||||||
|
/** Map normalized image coords (0..1) to energy-map pixel coords. */
|
||||||
|
export declare function normToEnergyPoint(normX: number, normY: number, em: EnergyMap): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
/** Map energy-map pixel coords back to normalized image coords. */
|
||||||
|
export declare function energyPointsToNorm(points: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[], em: EnergyMap): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[];
|
||||||
|
/** Map energy-map pixel coords back to original image coords. */
|
||||||
|
export declare function upscalePath(points: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[], scale: number, originW: number, originH: number): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[];
|
||||||
1
dist/utils/magneticLasso.js
vendored
Normal file
1
dist/utils/magneticLasso.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/utils/maskOutlinePaths.d.ts
vendored
1
dist/utils/maskOutlinePaths.d.ts
vendored
@ -1,5 +1,6 @@
|
|||||||
import { type SkPath } from '@shopify/react-native-skia';
|
import { type SkPath } from '@shopify/react-native-skia';
|
||||||
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
|
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: {
|
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
2
dist/utils/maskOutlinePaths.js
vendored
2
dist/utils/maskOutlinePaths.js
vendored
File diff suppressed because one or more lines are too long
2
dist/utils/maskSegmentRuntime.js
vendored
2
dist/utils/maskSegmentRuntime.js
vendored
File diff suppressed because one or more lines are too long
4
dist/utils/maskSegmentation.d.ts
vendored
4
dist/utils/maskSegmentation.d.ts
vendored
@ -85,6 +85,10 @@ export type RegionMaskData = {
|
|||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
wallSubLabels?: Uint8Array;
|
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) */
|
/** 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;
|
export declare function downsampleMaskDataForPaths(maskData: RegionMaskData, maxLongSide: number): RegionMaskData;
|
||||||
|
|||||||
2
dist/utils/maskSegmentation.js
vendored
2
dist/utils/maskSegmentation.js
vendored
File diff suppressed because one or more lines are too long
18
dist/utils/wallTextureSplit.d.ts
vendored
18
dist/utils/wallTextureSplit.d.ts
vendored
@ -1,6 +1,24 @@
|
|||||||
import type { SegmentMaskResult } from './maskSegmentation';
|
import type { SegmentMaskResult } from './maskSegmentation';
|
||||||
/** Placeholder value for non-wall pixels in wallSubLabels */
|
/** Placeholder value for non-wall pixels in wallSubLabels */
|
||||||
export declare const WALL_SUB_LABEL_NONE = 255;
|
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
|
* After semantic segmentation, subdivide the wall region into wall-1, wall-2… by source image texture features
|
||||||
*/
|
*/
|
||||||
|
|||||||
2
dist/utils/wallTextureSplit.js
vendored
2
dist/utils/wallTextureSplit.js
vendored
File diff suppressed because one or more lines are too long
5
docs/.gitignore
vendored
Normal file
5
docs/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Docusaurus docs site
|
||||||
|
node_modules/
|
||||||
|
.docusaurus/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
72
docs/docs/api/callbacks.md
Normal file
72
docs/docs/api/callbacks.md
Normal 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
65
docs/docs/api/index.md
Normal 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 |
|
||||||
15
docs/docs/api/interaction-config.md
Normal file
15
docs/docs/api/interaction-config.md
Normal 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 |
|
||||||
51
docs/docs/api/mask-config.md
Normal file
51
docs/docs/api/mask-config.md
Normal 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 ≈ 120–280, subtle lighting gradients ≈ 20–80 |
|
||||||
|
| `splitWallsCloseMaskRadius` | `number` | `3` | Morphological close radius for wall mask holes (windows, doors) before component labeling. 0 = disable |
|
||||||
|
| `manualSplitWalls` | `boolean` | `false` | When `true`, disables automatic texture-based wall splitting. Manual lasso partitioning is used instead |
|
||||||
|
| `manualSplitWallsMaxCount` | `number` | `8` | Maximum number of manual wall sub-regions defined by lasso |
|
||||||
|
| `manualSplitWallsGapAbsorbDilatePx` | `number` | `5` | Morphological dilation radius (seg pixels) to merge thin unassigned wall pockets adjacent to the drawn polygon |
|
||||||
|
| `magneticLasso` | `boolean` | `false` | When `true`, lasso mode uses edge-snapping via Sobel gradient + Dijkstra shortest-path |
|
||||||
|
| `activeContourRefine` | `boolean` | `false` | After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges |
|
||||||
|
|
||||||
|
When `splitWalls` is enabled, the single `wall` region is replaced by multiple `wall-N` sub-regions, each independently paintable and undoable. Old sessions with `regionName: 'wall'` cannot map to new sub-region names and must be repainted.
|
||||||
|
|
||||||
|
### Manual Wall Split (Lasso Mode)
|
||||||
|
|
||||||
|
When `manualSplitWalls` is enabled, automatic texture-based wall splitting is disabled. Instead, users must use the **Lasso** feature to manually draw polygons on the wall area:
|
||||||
|
|
||||||
|
- Call `ref.startLasso()` to enter lasso mode, then tap on wall areas to place polygon vertices.
|
||||||
|
- Enable `magneticLasso` for edge-snapping — paths will follow strong image edges (Sobel gradient + Dijkstra shortest-path).
|
||||||
|
- Enable `activeContourRefine` to automatically expand vertices outward toward the wall-mask boundary after lasso completion.
|
||||||
|
- Call `ref.endLasso()` to convert closed lasso polygons into `wall-N` sub-regions for painting.
|
||||||
|
- Use `splitWallsCloseMaskRadius` to fill wall mask holes (windows, doors) before component labeling during automatic split.
|
||||||
|
- Use `splitWallsEdgeBarrierThreshold` to prevent BFS from crossing strong edges (window frames, door frames) during automatic split.
|
||||||
21
docs/docs/api/paint-config.md
Normal file
21
docs/docs/api/paint-config.md
Normal 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 |
|
||||||
16
docs/docs/api/pipeline-config.md
Normal file
16
docs/docs/api/pipeline-config.md
Normal 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 |
|
||||||
18
docs/docs/api/props-image.md
Normal file
18
docs/docs/api/props-image.md
Normal 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.
|
||||||
25
docs/docs/api/props-semantic.md
Normal file
25
docs/docs/api/props-semantic.md
Normal 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`).
|
||||||
74
docs/docs/api/ref-methods.md
Normal file
74
docs/docs/api/ref-methods.md
Normal 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
26
docs/docs/api/storage.md
Normal 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
24
docs/docs/api/ui-controls.md
Normal file
24
docs/docs/api/ui-controls.md
Normal 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
203
docs/docs/basic-usage.md
Normal 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
17
docs/docs/dependencies.md
Normal 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 |
|
||||||
25
docs/docs/examples/custom-colors.md
Normal file
25
docs/docs/examples/custom-colors.md
Normal 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.
|
||||||
|
:::
|
||||||
18
docs/docs/examples/draft-recovery.md
Normal file
18
docs/docs/examples/draft-recovery.md
Normal 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.
|
||||||
21
docs/docs/examples/local-paths.md
Normal file
21
docs/docs/examples/local-paths.md
Normal 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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
19
docs/docs/examples/png-pre-warm.md
Normal file
19
docs/docs/examples/png-pre-warm.md
Normal 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 **100–250ms** 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
136
docs/docs/installation.md
Normal 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>
|
||||||
37
docs/docs/interaction-guide.md
Normal file
37
docs/docs/interaction-guide.md
Normal 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
56
docs/docs/intro.md
Normal 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
11
docs/docs/notes.md
Normal 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
92
docs/docs/performance.md
Normal 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`** | **~320–450ms** | Can tap regions, select colors, Shader paint |
|
||||||
|
| Outlines ready | `mask_paths_ready` | ~430–550ms | ~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 | ~10–40ms | 270×480 |
|
||||||
|
| High/low freq Skia textures | ~20–30ms | same |
|
||||||
|
| Layout scan + baseboard + pick table | ~90–120ms | 405×720 (1080p → longSide 720) |
|
||||||
|
| Full contour paths (async, non-blocking) | ~80–150ms | 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× | **320–450ms** | **450–700ms** |
|
||||||
|
| 1440×2560 (2K) | ~1.8× | **400–550ms** | **600–900ms** |
|
||||||
|
| 3840×2160 (4K) | ~4× | **500–750ms** | **800–1200ms** |
|
||||||
|
| 7680×4320 (8K) | ~16× | **0.8–1.5s** | **1.5–3s+** |
|
||||||
|
|
||||||
|
> **`<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.8–1.2× | 300–450ms | 500–800ms |
|
||||||
|
| Mid-range Android | 1.5–2.5× | 500–800ms | 700ms–1.2s |
|
||||||
|
| Low-end Android (4GB, old SoC) | 2.5–4× | 800ms–1.3s | 1–2s+ |
|
||||||
|
|
||||||
|
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) | ~320–800ms | **500ms–1s+** |
|
||||||
|
| Segmentation / pickMap duration | ~90–120ms | ~250–350ms |
|
||||||
|
|
||||||
|
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 **100–250ms** (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.8–1.5s `interactive` (with pre-warm).
|
||||||
|
5. 🔍 **Observability**: Watch Metro logs for `[MaskSegment]`, `[⏱ ...]` prefixes and `onWatch` `durationMs`.
|
||||||
33
docs/docs/project-structure.md
Normal file
33
docs/docs/project-structure.md
Normal 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
25
docs/docs/quick-start.md
Normal 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.
|
||||||
42
docs/docs/troubleshooting.md
Normal file
42
docs/docs/troubleshooting.md
Normal 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
121
docs/docusaurus.config.ts
Normal 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;
|
||||||
@ -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 detail(MaskSegmentWatchDetail)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 描述 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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` 后自动显示。
|
||||||
@ -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 导出 |
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
id: interaction-config
|
||||||
|
title: "Props:interactionConfig"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 👆 Props:interactionConfig
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 描述 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pickMapSearchRadiusPx` | `number` | `14` | 点击 pickMap 搜索半径(像素) |
|
||||||
|
| `kickMaskPickRadiusPx` | `number` | `36` | 踢脚线遮罩拾取半径 |
|
||||||
|
| `thinStripPadding` | `number` | `0.008` | 细条(踢脚线)点击扩展比例 |
|
||||||
|
| `regionPadding` | `number` | `0.003` | 普通区域点击扩展比例 |
|
||||||
|
| `initRegionFlashMs` | `number` | `1000` | 初始轮播中每条虚线轮廓持续时长(ms) |
|
||||||
|
| `enableInitRegionFlash` | `boolean` | `true` | 启用初始轮播动画 |
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: mask-config
|
||||||
|
title: "Props:maskConfig"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧩 Props:maskConfig
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 描述 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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 = 禁用)。可见墙面接缝 ≈ 120–280,细微光照渐变 ≈ 20–80 |
|
||||||
|
| `splitWallsCloseMaskRadius` | `number` | `3` | 组件标注前墙面遮罩孔洞(窗户、门)的形态学闭运算半径。设为 0 禁用 |
|
||||||
|
| `manualSplitWalls` | `boolean` | `false` | 为 `true` 时禁用自动纹理墙面分割,改为手动套索分区 |
|
||||||
|
| `manualSplitWallsMaxCount` | `number` | `8` | 套索定义的最大手动墙面子区域数 |
|
||||||
|
| `manualSplitWallsGapAbsorbDilatePx` | `number` | `5` | 形态学膨胀半径(分割像素),用于合并绘制多边形周围的未分配墙面薄缝 |
|
||||||
|
| `magneticLasso` | `boolean` | `false` | 为 `true` 时,套索模式使用 Sobel 梯度 + Dijkstra 最短路径进行边缘吸附 |
|
||||||
|
| `activeContourRefine` | `boolean` | `false` | 结束套索后,对每个多边形运行主动轮廓精炼,将顶点向外扩展到墙面遮罩边缘 |
|
||||||
|
|
||||||
|
启用 `splitWalls` 后,单个 `wall` 区域将被替换为多个 `wall-N` 子区域,每个子区域可独立上色和撤销。旧会话中 `regionName: 'wall'` 的记录无法映射到新的子区域名称,需重新上色。
|
||||||
|
|
||||||
|
### 手动墙面分割(套索模式)
|
||||||
|
|
||||||
|
当 `manualSplitWalls` 启用时,自动纹理墙面分割被禁用。用户必须使用 **套索(Lasso)** 功能在墙面上手动绘制多边形:
|
||||||
|
|
||||||
|
- 调用 `ref.startLasso()` 进入套索模式,然后在墙面区域点击放置多边形顶点。
|
||||||
|
- 启用 `magneticLasso` 进行边缘吸附 — 路径将沿图像强边缘走(Sobel 梯度 + Dijkstra 最短路径)。
|
||||||
|
- 启用 `activeContourRefine` 在套索完成后自动将顶点向外扩展到墙面遮罩边界。
|
||||||
|
- 调用 `ref.endLasso()` 将闭合套索多边形转换为可上色的 `wall-N` 子区域。
|
||||||
|
- 自动分割时使用 `splitWallsCloseMaskRadius` 填充墙面遮罩孔洞(窗户、门)。
|
||||||
|
- 自动分割时使用 `splitWallsEdgeBarrierThreshold` 阻止 BFS 跨越强边缘(窗框、门框)。
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
id: paint-config
|
||||||
|
title: "Props:paintConfig"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🖌️ Props:paintConfig
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 描述 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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` | 虚线轮廓描边宽度 |
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: pipeline-config
|
||||||
|
title: "Props:pipelineConfig"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔬 Props:pipelineConfig
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认值 | 描述 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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` | 分割期间的最大区域数 |
|
||||||
@ -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` 之一。
|
||||||
@ -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`)。
|
||||||
@ -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'`。
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
```
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
id: ui-controls
|
||||||
|
title: "Props:UI 控件与样式"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🎛️ Props:UI 控件与样式
|
||||||
|
|
||||||
|
| 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` | — | 自定义对比按钮渲染器 |
|
||||||
@ -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`。
|
||||||
@ -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 编解码 |
|
||||||
@ -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` 必须与后端/标注遮罩中使用的语义颜色匹配;不匹配会导致识别偏差。
|
||||||
|
:::
|
||||||
@ -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 中,以便后续恢复。
|
||||||
@ -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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
id: png-pre-warm
|
||||||
|
title: PNG 缓存预热(推荐)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔥 PNG 缓存预热(推荐)
|
||||||
|
|
||||||
|
在挂载 Canvas 前预热 PNG 解码缓存,可节省 **100–250ms** 的初始化时间。
|
||||||
|
|
||||||
|
```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` 可获得最佳性能。
|
||||||
@ -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:..` | 相对路径依赖 |
|
||||||
@ -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 简化去除冗余顶点
|
||||||
|
- 结果:多边形贴合真实墙面轮廓,而非原始点击位置
|
||||||
@ -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 17(Android)
|
||||||
|
- 📦 CocoaPods(iOS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步
|
||||||
|
|
||||||
|
- **[安装](/docs/installation)** — 在项目中配置库
|
||||||
|
- **[快速开始](/docs/quick-start)** — 运行开发 Demo
|
||||||
|
- **[基本用法](/docs/basic-usage)** — 入门最小示例
|
||||||
|
- **[API 参考](/docs/api)** — 完整的 Props 和方法文档
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
id: notes
|
||||||
|
title: 注意事项
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📝 注意事项
|
||||||
|
|
||||||
|
- 遮罩图像应为与原始图像同尺寸的语义色块图(黑色背景 + 纯色区域)。`max(B,G,R) < blackThreshold`(默认 30)的像素将被排除在分割之外。
|
||||||
|
- OpenCV 分割在 JS 线程上运行;非常大的图像可能导致掉帧。使用 `pipelineConfig.maxImageLongSide` 限制处理分辨率。
|
||||||
|
- iOS 相册访问需要照片权限(仅在启用 `showDebugPickers` 时需要)。
|
||||||
|
- `semanticColors` 必须与后端/标注遮罩中使用的语义颜色匹配;不匹配会导致识别偏差。
|
||||||
@ -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`** | **~320–450ms** | 可点击区域、选择颜色、Shader 上色 |
|
||||||
|
| 轮廓就绪 | `mask_paths_ready` | ~430–550ms | `interactive` 后约 100ms;轮播轮廓可显示 |
|
||||||
|
|
||||||
|
`interactive` **不等待**轮廓路径;`mask_paths_ready` 仅影响初始轮播和可选的 UI 提示。
|
||||||
|
|
||||||
|
同图子步骤耗时大小(`__DEV__` 日志,默认 pipeline):
|
||||||
|
|
||||||
|
| 子步骤 | 大约耗时 | 工作分辨率 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OpenCV LAB 高/低频 | ~10–40ms | 270×480 |
|
||||||
|
| 高/低频 Skia 纹理 | ~20–30ms | 同上 |
|
||||||
|
| 布局扫描 + 踢脚线 + pick 表 | ~90–120ms | 405×720(1080p → longSide 720) |
|
||||||
|
| 全轮廓路径(异步,非阻塞) | ~80–150ms | 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× | **320–450ms** | **450–700ms** |
|
||||||
|
| 1440×2560(2K) | ~1.8× | **400–550ms** | **600–900ms** |
|
||||||
|
| 3840×2160(4K) | ~4× | **500–750ms** | **800–1200ms** |
|
||||||
|
| 7680×4320(8K) | ~16× | **0.8–1.5s** | **1.5–3s+** |
|
||||||
|
|
||||||
|
> **`<300ms` interactive**:1080p + 预热 + 默认 pipeline + 高端设备上可达,但属 **乐观估计** — 不应视为全设备 SLA。
|
||||||
|
|
||||||
|
## 设备等级(1080p,默认 Pipeline)
|
||||||
|
|
||||||
|
相对于约 320ms 的开发环境基线:
|
||||||
|
|
||||||
|
| 等级 | 相对倍数 | 预热后 `interactive` | 冷启动 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 旗舰 iOS / 新款旗舰 Android | 0.8–1.2× | 300–450ms | 500–800ms |
|
||||||
|
| 中端 Android | 1.5–2.5× | 500–800ms | 700ms–1.2s |
|
||||||
|
| 低端 Android(4GB,旧 SoC) | 2.5–4× | 800ms–1.3s | 1–2s+ |
|
||||||
|
|
||||||
|
Android 额外开销主要来自:JS ↔ OpenCV 桥接、内存带宽/GC、Skia 纹理上传。
|
||||||
|
|
||||||
|
## 提高 maxImageLongSide 的影响
|
||||||
|
|
||||||
|
将 `pipelineConfig.maxImageLongSide` 设为 **1280**(高于默认 720)会使分割工作区变为约 720×1280,像素数约为 720 档的 **3 倍**:
|
||||||
|
|
||||||
|
| 场景 | 默认 720 | 提高到 1280 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1080p `interactive`(中端设备) | ~320–800ms | **500ms–1s+** |
|
||||||
|
| 分割 / pickMap 耗时 | ~90–120ms | ~250–350ms |
|
||||||
|
|
||||||
|
更高精度带来更长的初始化时间。要保持在 **`<500ms` interactive**,保留默认 **720**;必要时可降至 **640**。
|
||||||
|
|
||||||
|
## 优化建议
|
||||||
|
|
||||||
|
1. 🚀 **PNG 预热(推荐)**:在下载/提取图片后、导航到上色界面前调用 `prewarmPngBgrCacheAsync`。通常可节省 **100–250ms**(低端设备收益最大)。
|
||||||
|
|
||||||
|
```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.8–1.5s 的 `interactive`(含预热)。
|
||||||
|
5. 🔍 **可观测性**:观察 Metro 日志中的 `[MaskSegment]`、`[⏱ ...]` 前缀和 `onWatch` `durationMs`。
|
||||||
@ -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/
|
||||||
|
```
|
||||||
@ -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 配置,完全模拟消费方环境。
|
||||||
@ -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
18283
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
docs/package.json
Normal file
48
docs/package.json
Normal 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
85
docs/sidebars.ts
Normal 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
30
docs/src/css/custom.css
Normal 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%);
|
||||||
|
}
|
||||||
99
docs/src/pages/index.module.css
Normal file
99
docs/src/pages/index.module.css
Normal 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
140
docs/src/pages/index.tsx
Normal 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:
|
||||||
|
'~320–450ms 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
6
docs/static/img/logo.svg
vendored
Normal 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
6
docs/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@docusaurus/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
183
example/App.tsx
183
example/App.tsx
@ -24,6 +24,7 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
|
|
||||||
import MaskSegmentCanvas, {
|
import MaskSegmentCanvas, {
|
||||||
type BgrColor,
|
type BgrColor,
|
||||||
|
type ManualWallPartition,
|
||||||
type MaskSegmentCanvasRef,
|
type MaskSegmentCanvasRef,
|
||||||
type MaskSegmentSession,
|
type MaskSegmentSession,
|
||||||
type MaskSegmentWatchState,
|
type MaskSegmentWatchState,
|
||||||
@ -122,6 +123,11 @@ function App(): React.JSX.Element {
|
|||||||
// Demo mode
|
// Demo mode
|
||||||
const [useCustomColors, setUseCustomColors] = useState(false);
|
const [useCustomColors, setUseCustomColors] = useState(false);
|
||||||
const [splitWalls, setSplitWalls] = 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 [pipelinePreset, setPipelinePreset] = useState<PipelinePreset>('medium');
|
||||||
const [groupIndex, setGroupIndex] = useState(0);
|
const [groupIndex, setGroupIndex] = useState(0);
|
||||||
|
|
||||||
@ -272,6 +278,77 @@ function App(): React.JSX.Element {
|
|||||||
[showToast],
|
[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
|
// render: error / loading / ready
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@ -381,13 +458,65 @@ function App(): React.JSX.Element {
|
|||||||
(split walls)
|
(split walls)
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* canvas */}
|
{/* canvas */}
|
||||||
<View style={styles.canvasHost}>
|
<View style={styles.canvasHost}>
|
||||||
<MaskSegmentCanvas
|
<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}
|
ref={canvasRef}
|
||||||
style={styles.canvas}
|
style={styles.canvas}
|
||||||
originUrl={imagePaths.origin}
|
originUrl={imagePaths.origin}
|
||||||
@ -399,6 +528,11 @@ function App(): React.JSX.Element {
|
|||||||
...DEFAULT_MASK_CONFIG,
|
...DEFAULT_MASK_CONFIG,
|
||||||
maxRegionColors: 6,
|
maxRegionColors: 6,
|
||||||
splitWalls,
|
splitWalls,
|
||||||
|
manualSplitWalls,
|
||||||
|
manualSplitWallsMaxCount: 8,
|
||||||
|
magneticLasso,
|
||||||
|
activeContourRefine,
|
||||||
|
splitWallsEdgeBarrierThreshold: splitEdgeBarrier ? 160 : 0,
|
||||||
}}
|
}}
|
||||||
paintConfig={{
|
paintConfig={{
|
||||||
...DEFAULT_PAINT_CONFIG,
|
...DEFAULT_PAINT_CONFIG,
|
||||||
@ -493,6 +627,46 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Text style={styles.actionBtnText}>Export session</Text>
|
<Text style={styles.actionBtnText}>Export session</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -585,6 +759,9 @@ const styles = StyleSheet.create({
|
|||||||
modeChipActive: {
|
modeChipActive: {
|
||||||
backgroundColor: '#4363D8',
|
backgroundColor: '#4363D8',
|
||||||
},
|
},
|
||||||
|
modeChipLassoActive: {
|
||||||
|
backgroundColor: '#00C853',
|
||||||
|
},
|
||||||
modeChipText: {
|
modeChipText: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
@ -697,6 +874,10 @@ const styles = StyleSheet.create({
|
|||||||
actionBtnDanger: {
|
actionBtnDanger: {
|
||||||
borderColor: '#e88',
|
borderColor: '#e88',
|
||||||
},
|
},
|
||||||
|
actionBtnLassoActive: {
|
||||||
|
backgroundColor: '#FF6B35',
|
||||||
|
borderColor: '#FF6B35',
|
||||||
|
},
|
||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#555',
|
color: '#555',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "react-native-mask-segment-canvas",
|
"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",
|
"description": "React Native mask segmentation canvas library: OpenCV semantic segmentation + SkSL Shader coloring",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,33 @@ export type MaskSegmentConfig = {
|
|||||||
splitWallsChromaBlurRadius?: number;
|
splitWallsChromaBlurRadius?: number;
|
||||||
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
/** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */
|
||||||
splitWallsNeutralChromaMax?: number;
|
splitWallsNeutralChromaMax?: number;
|
||||||
|
/**
|
||||||
|
* Wall split: raw per-channel BGR Sobel gradient threshold for edge barriers
|
||||||
|
* (0 = disabled). Uses un-normalized 8‑bit Sobel magnitude on each B/G/R
|
||||||
|
* channel (max per pixel, range ≈ 0–1442). Visible wall seams ≈ 120–280,
|
||||||
|
* subtle lighting gradients ≈ 20–80. Default: 160.
|
||||||
|
*/
|
||||||
|
splitWallsEdgeBarrierThreshold?: number;
|
||||||
|
/**
|
||||||
|
* Morphological close radius for wall mask holes before component labeling.
|
||||||
|
* Non-wall pixels inside the wall boundary (windows, doors, mask artefacts)
|
||||||
|
* are temporarily filled so the BFS can bridge across them. Default: 3.
|
||||||
|
* Set to 0 to disable.
|
||||||
|
*/
|
||||||
|
splitWallsCloseMaskRadius?: number;
|
||||||
|
/** When true, disables automatic texture-based wall splitting (splitWalls). Manual lasso partitioning is used instead. */
|
||||||
|
manualSplitWalls?: boolean;
|
||||||
|
/** wall mask only, max number of manual wall sub-regions defined by lasso */
|
||||||
|
manualSplitWallsMaxCount?: number;
|
||||||
|
/**
|
||||||
|
* Manual lasso: morphological dilation radius (seg pixels) used to merge thin
|
||||||
|
* unassigned wall pockets adjacent to the drawn polygon.
|
||||||
|
*/
|
||||||
|
manualSplitWallsGapAbsorbDilatePx?: number;
|
||||||
|
/** When true, lasso mode uses edge-snapping (Sobel gradient + Dijkstra shortest-path). Default: false. */
|
||||||
|
magneticLasso?: boolean;
|
||||||
|
/** After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges. Default: false. */
|
||||||
|
activeContourRefine?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaintConfig = {
|
export type PaintConfig = {
|
||||||
@ -138,6 +165,23 @@ export type PaintBrushRequiredPayload = {
|
|||||||
|
|
||||||
export type PaintCallbackPayload = PaintSuccessPayload | 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 = {
|
export type MaskSegmentCanvasRef = {
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
swap: (showOrigin?: boolean) => void;
|
swap: (showOrigin?: boolean) => void;
|
||||||
@ -154,6 +198,20 @@ export type MaskSegmentCanvasRef = {
|
|||||||
getPaintedRegions: () => PaintedRegionRecord[];
|
getPaintedRegions: () => PaintedRegionRecord[];
|
||||||
/** Returns the most recent auto-export or save() result, if any. */
|
/** Returns the most recent auto-export or save() result, if any. */
|
||||||
getLastExport?: () => SavePaintResult | null;
|
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 = {
|
export type MaskSegmentCanvasProps = {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ export { default } from './components/MaskSegmentCanvas';
|
|||||||
export type {
|
export type {
|
||||||
BgrColor,
|
BgrColor,
|
||||||
InteractionConfig,
|
InteractionConfig,
|
||||||
|
LassoPolygon,
|
||||||
|
ManualWallPartition,
|
||||||
MaskSegmentCanvasProps,
|
MaskSegmentCanvasProps,
|
||||||
MaskSegmentCanvasRef,
|
MaskSegmentCanvasRef,
|
||||||
MaskSegmentConfig,
|
MaskSegmentConfig,
|
||||||
|
|||||||
380
src/utils/activeContour.ts
Normal file
380
src/utils/activeContour.ts
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* Active Contour Model — greedy snake + balloon force.
|
||||||
|
*
|
||||||
|
* After the user finishes a lasso polygon, this module refines the boundary
|
||||||
|
* vertices outward toward the true wall-mask edge. Each vertex samples
|
||||||
|
* positions along its outward normal and picks the one with lowest energy.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Subdivide polygon to get evenly-spaced control points
|
||||||
|
* 2. For each iteration (3-5 rounds):
|
||||||
|
* a. Compute outward normal at each point
|
||||||
|
* b. Sample N positions along the normal (outward first, then inward)
|
||||||
|
* c. Score each position: E = E_edge + E_smooth
|
||||||
|
* d. Move vertex to min-energy position (constrained to wall mask)
|
||||||
|
* 3. Douglas-Peucker simplify
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
isNormPointOnWallMask,
|
||||||
|
type WallMaskSample,
|
||||||
|
} from './magneticLasso';
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Types
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export type ActiveContourOpts = {
|
||||||
|
/** Number of greedy iterations (default 3). */
|
||||||
|
iterations?: number;
|
||||||
|
/** Number of sample positions along normal per direction (default 6). */
|
||||||
|
samplesPerDirection?: number;
|
||||||
|
/** Step size (norm coords) between samples (default 0.003). */
|
||||||
|
sampleStep?: number;
|
||||||
|
/** Smoothness weight — higher keeps vertices more uniformly spaced (default 0.15). */
|
||||||
|
smoothWeight?: number;
|
||||||
|
/** Edge weight — higher makes contour hug mask boundary (default 1.0). */
|
||||||
|
edgeWeight?: number;
|
||||||
|
/** Balloon bias — extra outward push per iteration (default 0.002). */
|
||||||
|
balloonForce?: number;
|
||||||
|
/** Minimum vertex count for a polygon to be refined (default 4). */
|
||||||
|
minVertices?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OPTS: Required<ActiveContourOpts> = {
|
||||||
|
iterations: 3,
|
||||||
|
samplesPerDirection: 6,
|
||||||
|
sampleStep: 0.003,
|
||||||
|
smoothWeight: 0.15,
|
||||||
|
edgeWeight: 1.0,
|
||||||
|
balloonForce: 0.002,
|
||||||
|
minVertices: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Helpers
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
function computeOutwardNormal(
|
||||||
|
prev: { x: number; y: number },
|
||||||
|
curr: { x: number; y: number },
|
||||||
|
next: { x: number; y: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const dx1 = curr.x - prev.x;
|
||||||
|
const dy1 = curr.y - prev.y;
|
||||||
|
const dx2 = next.x - curr.x;
|
||||||
|
const dy2 = next.y - curr.y;
|
||||||
|
|
||||||
|
// Average tangent direction at curr
|
||||||
|
const tx = dx1 + dx2;
|
||||||
|
const ty = dy1 + dy2;
|
||||||
|
|
||||||
|
// Normal (rotate 90° CCW) — two candidates
|
||||||
|
const nx1 = -ty;
|
||||||
|
const ny1 = tx;
|
||||||
|
const nx2 = ty;
|
||||||
|
const ny2 = -tx;
|
||||||
|
|
||||||
|
// Choose the outward normal: the one that points away from centroid
|
||||||
|
// A simple heuristic: the direction with positive dot product with
|
||||||
|
// (curr - centroid). Since we can't compute centroid cheaply each time,
|
||||||
|
// use a nearby point approximation: the direction that points to larger
|
||||||
|
// edge energy (i.e., toward wall boundary). We'll pick the direction
|
||||||
|
// that pushes the polygon out.
|
||||||
|
//
|
||||||
|
// For now we use the convention: the normal pointing toward positive
|
||||||
|
// sweep (CCW polygon → normal should point outward).
|
||||||
|
// We'll verify by computing the cross product of the normal with the
|
||||||
|
// edge direction.
|
||||||
|
const cross1 = dx2 * ny1 - dy2 * nx1;
|
||||||
|
const cross2 = dx2 * ny2 - dy2 * nx2;
|
||||||
|
|
||||||
|
const len = Math.hypot(nx1, ny1);
|
||||||
|
if (len < 1e-8) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a CCW polygon, the outward normal is the one with negative cross product
|
||||||
|
const [nx, ny] = cross1 < cross2 ? [nx1, ny1] : [nx2, ny2];
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: nx / len,
|
||||||
|
y: ny / len,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance to the nearest wall-mask boundary pixel.
|
||||||
|
* Returns [0..∞) in normalized coordinate space.
|
||||||
|
* Uses a fast spiral search within maxRadius.
|
||||||
|
*/
|
||||||
|
function distToWallBoundary(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
maxRadius: number,
|
||||||
|
): number {
|
||||||
|
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||||
|
if (cols <= 0 || rows <= 0) return maxRadius;
|
||||||
|
|
||||||
|
const cx = Math.round(normX * cols);
|
||||||
|
const cy = Math.round(normY * rows);
|
||||||
|
const r = Math.ceil(maxRadius);
|
||||||
|
let best = maxRadius + 1;
|
||||||
|
|
||||||
|
for (let dy = -r; dy <= r; dy++) {
|
||||||
|
for (let dx = -r; dx <= r; dx++) {
|
||||||
|
const x = cx + dx;
|
||||||
|
const y = cy + dy;
|
||||||
|
if (x < 0 || y < 0 || x >= cols || y >= rows) continue;
|
||||||
|
const i = y * cols + x;
|
||||||
|
if (labels[i] !== wallSemanticIdx || baseboardBinary[i]) continue;
|
||||||
|
// Check if this is a boundary pixel (adjacent to non-wall)
|
||||||
|
if (
|
||||||
|
x === 0 || y === 0 || x === cols - 1 || y === rows - 1 ||
|
||||||
|
labels[i - 1] !== wallSemanticIdx ||
|
||||||
|
labels[i + 1] !== wallSemanticIdx ||
|
||||||
|
labels[i - cols] !== wallSemanticIdx ||
|
||||||
|
labels[i + cols] !== wallSemanticIdx
|
||||||
|
) {
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist < best) best = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert from seg pixels to normalized space
|
||||||
|
return best / Math.max(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a candidate position:
|
||||||
|
* E = edgeWeight * edgeEnergy + smoothWeight * smoothEnergy - balloonForce
|
||||||
|
*
|
||||||
|
* Lower score = better.
|
||||||
|
* Edge energy is distance to nearest wall boundary (0 = on boundary).
|
||||||
|
* Smooth energy penalizes large deviations from the median of neighbors.
|
||||||
|
*/
|
||||||
|
function scorePosition(
|
||||||
|
nx: number,
|
||||||
|
ny: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
maxEdgeDist: number,
|
||||||
|
neighbors: { x: number; y: number }[],
|
||||||
|
opts: Required<ActiveContourOpts>,
|
||||||
|
): number {
|
||||||
|
if (!isNormPointOnWallMask(nx, ny, mask)) {
|
||||||
|
return 1e6; // reject
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge = distToWallBoundary(nx, ny, mask, maxEdgeDist) / maxEdgeDist;
|
||||||
|
|
||||||
|
let smooth = 0;
|
||||||
|
if (neighbors.length >= 2) {
|
||||||
|
const n0 = neighbors[0];
|
||||||
|
const n1 = neighbors[neighbors.length - 1];
|
||||||
|
const mx = (n0.x + n1.x) / 2;
|
||||||
|
const my = (n0.y + n1.y) / 2;
|
||||||
|
smooth = Math.hypot(nx - mx, ny - my);
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.edgeWeight * edge + opts.smoothWeight * smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Subdivision — insert points where consecutive vertices are far apart
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
function subdividePolygon(
|
||||||
|
vertices: { x: number; y: number }[],
|
||||||
|
maxGap: number,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
if (vertices.length < 2) return [...vertices];
|
||||||
|
const result: { x: number; y: number }[] = [];
|
||||||
|
const n = vertices.length;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = vertices[i];
|
||||||
|
const b = vertices[(i + 1) % n];
|
||||||
|
result.push({ ...a });
|
||||||
|
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||||
|
const steps = Math.floor(dist / maxGap);
|
||||||
|
if (steps > 1) {
|
||||||
|
for (let s = 1; s < steps; s++) {
|
||||||
|
const t = s / steps;
|
||||||
|
result.push({
|
||||||
|
x: a.x + t * (b.x - a.x),
|
||||||
|
y: a.y + t * (b.y - a.y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Main
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refine a single closed lasso polygon to hug the wall-mask outer boundary.
|
||||||
|
*
|
||||||
|
* Returns a new vertex list (not mutated in place). Returns the original
|
||||||
|
* polygon unchanged if it has too few vertices or no wall mask is given.
|
||||||
|
*/
|
||||||
|
export function refinePolygonToWallEdges(
|
||||||
|
vertices: { x: number; y: number }[],
|
||||||
|
mask: WallMaskSample,
|
||||||
|
opts?: ActiveContourOpts,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
const o = { ...DEFAULT_OPTS, ...opts };
|
||||||
|
if (vertices.length < o.minVertices || !mask) {
|
||||||
|
return [...vertices];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Subdivide to get evenly-spaced control points
|
||||||
|
let points = subdividePolygon(vertices, o.sampleStep * 2);
|
||||||
|
|
||||||
|
// 2. Greedy iteration loop
|
||||||
|
const maxEdgeDist = o.samplesPerDirection * o.sampleStep * Math.max(mask.cols, mask.rows);
|
||||||
|
const segPxEdgeDist = o.samplesPerDirection * o.sampleStep * Math.max(mask.cols, mask.rows);
|
||||||
|
|
||||||
|
for (let iter = 0; iter < o.iterations; iter++) {
|
||||||
|
const newPoints: { x: number; y: number }[] = [];
|
||||||
|
const m = points.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const prev = points[(i - 1 + m) % m];
|
||||||
|
const curr = points[i];
|
||||||
|
const next = points[(i + 1) % m];
|
||||||
|
|
||||||
|
const normal = computeOutwardNormal(prev, curr, next);
|
||||||
|
if (normal.x === 0 && normal.y === 0) {
|
||||||
|
newPoints.push({ ...curr });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestPt = { ...curr };
|
||||||
|
let bestScore = scorePosition(
|
||||||
|
curr.x, curr.y, mask, segPxEdgeDist,
|
||||||
|
[prev, next], o,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply balloon force: bias outward by extra offset
|
||||||
|
const balloonOffset = o.balloonForce * (iter + 1);
|
||||||
|
|
||||||
|
// Sample positions: outward first (balloon force region), then inward
|
||||||
|
for (let d = 1; d <= o.samplesPerDirection; d++) {
|
||||||
|
const dist = d * o.sampleStep;
|
||||||
|
|
||||||
|
// Outward (balloon direction)
|
||||||
|
const nxOut = curr.x + normal.x * (dist + balloonOffset);
|
||||||
|
const nyOut = curr.y + normal.y * (dist + balloonOffset);
|
||||||
|
const scoreOut = scorePosition(
|
||||||
|
nxOut, nyOut, mask, segPxEdgeDist,
|
||||||
|
[newPoints.length > 0 ? newPoints[newPoints.length - 1] : points[(i - 1 + m) % m], next],
|
||||||
|
o,
|
||||||
|
);
|
||||||
|
if (scoreOut < bestScore) {
|
||||||
|
bestScore = scoreOut;
|
||||||
|
bestPt = { x: nxOut, y: nyOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inward (conservative)
|
||||||
|
const nxIn = curr.x - normal.x * dist;
|
||||||
|
const nyIn = curr.y - normal.y * dist;
|
||||||
|
const scoreIn = scorePosition(
|
||||||
|
nxIn, nyIn, mask, segPxEdgeDist,
|
||||||
|
[newPoints.length > 0 ? newPoints[newPoints.length - 1] : points[(i - 1 + m) % m], next],
|
||||||
|
o,
|
||||||
|
);
|
||||||
|
if (scoreIn < bestScore) {
|
||||||
|
bestScore = scoreIn;
|
||||||
|
bestPt = { x: nxIn, y: nyIn };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newPoints.push(bestPt);
|
||||||
|
}
|
||||||
|
|
||||||
|
points = newPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Douglas-Peucker simplify
|
||||||
|
const simplified = douglasPeucker(points, 0.002);
|
||||||
|
if (simplified.length < 3) return [...vertices];
|
||||||
|
|
||||||
|
// Ensure closed
|
||||||
|
const first = simplified[0];
|
||||||
|
const last = simplified[simplified.length - 1];
|
||||||
|
if (Math.hypot(first.x - last.x, first.y - last.y) > 0.0005) {
|
||||||
|
simplified.push({ ...first });
|
||||||
|
}
|
||||||
|
|
||||||
|
return simplified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Douglas-Peucker (inlined for independence)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
function douglasPeucker(
|
||||||
|
path: { x: number; y: number }[],
|
||||||
|
epsilon: number,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
if (path.length <= 2) return [...path];
|
||||||
|
|
||||||
|
const keep = new Uint8Array(path.length);
|
||||||
|
keep[0] = 1;
|
||||||
|
keep[path.length - 1] = 1;
|
||||||
|
|
||||||
|
function recurse(s: number, e: number) {
|
||||||
|
if (e - s <= 1) return;
|
||||||
|
const ax = path[s].x;
|
||||||
|
const ay = path[s].y;
|
||||||
|
const bx = path[e].x;
|
||||||
|
const by = path[e].y;
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
let maxDist = 0;
|
||||||
|
let maxIdx = s;
|
||||||
|
for (let i = s + 1; i < e; i++) {
|
||||||
|
let dist: number;
|
||||||
|
if (lenSq === 0) {
|
||||||
|
dist = Math.hypot(path[i].x - ax, path[i].y - ay);
|
||||||
|
} else {
|
||||||
|
const t = Math.max(0, Math.min(1,
|
||||||
|
((path[i].x - ax) * dx + (path[i].y - ay) * dy) / lenSq,
|
||||||
|
));
|
||||||
|
const px = ax + t * dx;
|
||||||
|
const py = ay + t * dy;
|
||||||
|
dist = Math.hypot(path[i].x - px, path[i].y - py);
|
||||||
|
}
|
||||||
|
if (dist > maxDist) {
|
||||||
|
maxDist = dist;
|
||||||
|
maxIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxDist > epsilon) {
|
||||||
|
keep[maxIdx] = 1;
|
||||||
|
recurse(s, maxIdx);
|
||||||
|
recurse(maxIdx, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recurse(0, path.length - 1);
|
||||||
|
|
||||||
|
// Enforce minimum distance between consecutive anchors
|
||||||
|
const result: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
if (!keep[i]) continue;
|
||||||
|
if (result.length > 0) {
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
if (Math.hypot(path[i].x - last.x, path[i].y - last.y) < 0.001) continue;
|
||||||
|
}
|
||||||
|
result.push({ x: path[i].x, y: path[i].y });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
621
src/utils/magneticLasso.ts
Normal file
621
src/utils/magneticLasso.ts
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
/**
|
||||||
|
* Magnetic Lasso — edge-snapping polygon placement for manual wall splitting.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. buildEnergyMap → grayscale + downsample + Sobel gradient → energy grid
|
||||||
|
* 2. findShortestPath → Dijkstra 8-connected on low-energy (edge) pixels
|
||||||
|
* 3. extractCornerPoints → Douglas-Peucker simplification on raw path
|
||||||
|
* 4. upscalePath → map energy-space coords back to original image coords
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* Types
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export type EnergyMap = {
|
||||||
|
/** Float32Array per-pixel energy values [0…1]; low = edge, high = flat */
|
||||||
|
map: Float32Array;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
/** Downscale ratio: energyDim / sourceDim (≈ em.w / sourceCols) */
|
||||||
|
scale: number;
|
||||||
|
/** Optional 0/1 mask at energy resolution; 0 = blocked for pathfinding */
|
||||||
|
traversable?: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Seg-resolution wall mask used to constrain lasso vertices. */
|
||||||
|
export type WallMaskSample = {
|
||||||
|
labels: Uint8Array;
|
||||||
|
baseboardBinary: Uint8Array;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
wallSemanticIdx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when norm coords fall on a wall semantic pixel (excludes baseboard). */
|
||||||
|
export function isNormPointOnWallMask(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
): boolean {
|
||||||
|
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||||
|
if (wallSemanticIdx < 0 || cols <= 0 || rows <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cx = Math.min(cols - 1, Math.max(0, Math.floor(normX * cols)));
|
||||||
|
const cy = Math.min(rows - 1, Math.max(0, Math.floor(normY * rows)));
|
||||||
|
const i = cy * cols + cx;
|
||||||
|
if (baseboardBinary[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return labels[i] === wallSemanticIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVerticesToWallMask<T extends { x: number; y: number }>(
|
||||||
|
vertices: T[],
|
||||||
|
mask: WallMaskSample,
|
||||||
|
): T[] {
|
||||||
|
return vertices.filter(v => isNormPointOnWallMask(v.x, v.y, mask));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWallPixel(mask: WallMaskSample, x: number, y: number): boolean {
|
||||||
|
const { labels, baseboardBinary, cols, rows, wallSemanticIdx } = mask;
|
||||||
|
if (x < 0 || y < 0 || x >= cols || y >= rows || wallSemanticIdx < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const i = y * cols + x;
|
||||||
|
if (baseboardBinary[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return labels[i] === wallSemanticIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWallBoundaryPixel(mask: WallMaskSample, x: number, y: number): boolean {
|
||||||
|
if (!isWallPixel(mask, x, y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { cols, rows } = mask;
|
||||||
|
if (x === 0 || y === 0 || x === cols - 1 || y === rows - 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
!isWallPixel(mask, x - 1, y) ||
|
||||||
|
!isWallPixel(mask, x + 1, y) ||
|
||||||
|
!isWallPixel(mask, x, y - 1) ||
|
||||||
|
!isWallPixel(mask, x, y + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWallCornerBoundaryPixel(
|
||||||
|
mask: WallMaskSample,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): boolean {
|
||||||
|
if (!isWallBoundaryPixel(mask, x, y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const left = !isWallPixel(mask, x - 1, y);
|
||||||
|
const right = !isWallPixel(mask, x + 1, y);
|
||||||
|
const up = !isWallPixel(mask, x, y - 1);
|
||||||
|
const down = !isWallPixel(mask, x, y + 1);
|
||||||
|
return (left || right) && (up || down);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snap a normalized point to the nearest wall-mask boundary pixel when the
|
||||||
|
* touch falls within `snapRadiusSegPx` (segmentation resolution) of the edge.
|
||||||
|
*/
|
||||||
|
export function snapNormPointToWallEdge(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
snapRadiusSegPx = 12,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const snapped = searchWallSnapTarget(
|
||||||
|
normX, normY, mask, snapRadiusSegPx, 'edge',
|
||||||
|
);
|
||||||
|
return snapped ?? { x: normX, y: normY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer wall-mask corner pixels (L-shaped outer boundary), then plain edge.
|
||||||
|
* Used when the user taps without dragging.
|
||||||
|
*/
|
||||||
|
export function snapNormPointToWallCornerOrEdge(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
snapRadiusSegPx = 16,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const corner = searchWallSnapTarget(
|
||||||
|
normX, normY, mask, snapRadiusSegPx, 'corner',
|
||||||
|
);
|
||||||
|
if (corner) {
|
||||||
|
return corner;
|
||||||
|
}
|
||||||
|
const edge = searchWallSnapTarget(
|
||||||
|
normX, normY, mask, snapRadiusSegPx, 'edge',
|
||||||
|
);
|
||||||
|
return edge ?? { x: normX, y: normY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* During vertex drag: snap to corner/edge when near, otherwise keep interior
|
||||||
|
* wall points so the anchor can move freely on the wall mask.
|
||||||
|
*/
|
||||||
|
export function resolveLassoWallDragPoint(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
snapRadiusSegPx = 12,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const snapped = snapNormPointToWallCornerOrEdge(
|
||||||
|
normX, normY, mask, snapRadiusSegPx,
|
||||||
|
);
|
||||||
|
if (isNormPointOnWallMask(snapped.x, snapped.y, mask)) {
|
||||||
|
return snapped;
|
||||||
|
}
|
||||||
|
if (isNormPointOnWallMask(normX, normY, mask)) {
|
||||||
|
return { x: normX, y: normY };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchWallSnapTarget(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
mask: WallMaskSample,
|
||||||
|
snapRadiusSegPx: number,
|
||||||
|
mode: 'corner' | 'edge',
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
const { cols, rows } = mask;
|
||||||
|
if (cols <= 0 || rows <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const px = normX * cols;
|
||||||
|
const py = normY * rows;
|
||||||
|
const cx = Math.floor(px);
|
||||||
|
const cy = Math.floor(py);
|
||||||
|
const radius = Math.max(1, Math.ceil(snapRadiusSegPx));
|
||||||
|
const radiusSq = snapRadiusSegPx * snapRadiusSegPx;
|
||||||
|
|
||||||
|
let bestDistSq = Infinity;
|
||||||
|
let bestX = -1;
|
||||||
|
let bestY = -1;
|
||||||
|
|
||||||
|
for (let dy = -radius; dy <= radius; dy++) {
|
||||||
|
for (let dx = -radius; dx <= radius; dx++) {
|
||||||
|
const x = cx + dx;
|
||||||
|
const y = cy + dy;
|
||||||
|
if (x < 0 || y < 0 || x >= cols || y >= rows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const onEdge = isWallBoundaryPixel(mask, x, y);
|
||||||
|
if (!onEdge) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mode === 'corner' && !isWallCornerBoundaryPixel(mask, x, y)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const distSq = (px - (x + 0.5)) ** 2 + (py - (y + 0.5)) ** 2;
|
||||||
|
if (distSq <= radiusSq && distSq < bestDistSq) {
|
||||||
|
bestDistSq = distSq;
|
||||||
|
bestX = x;
|
||||||
|
bestY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestX < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (bestX + 0.5) / cols,
|
||||||
|
y: (bestY + 0.5) / rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWallAllowedMask(
|
||||||
|
labels: Uint8Array,
|
||||||
|
baseboardBinary: Uint8Array,
|
||||||
|
wallSemanticIdx: number,
|
||||||
|
): Uint8Array | null {
|
||||||
|
if (wallSemanticIdx < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allowedMask = new Uint8Array(labels.length);
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
allowedMask[i] =
|
||||||
|
labels[i] === wallSemanticIdx && !baseboardBinary[i] ? 1 : 0;
|
||||||
|
}
|
||||||
|
return allowedMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* buildEnergyMap
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
const GRAY_R = 0.299;
|
||||||
|
const GRAY_G = 0.587;
|
||||||
|
const GRAY_B = 0.114;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build per-pixel energy map from BGR buffer.
|
||||||
|
* 1. Convert to grayscale via luminance weights
|
||||||
|
* 2. Downsample so longest side ≤ targetMaxSide
|
||||||
|
* 3. Apply Sobel 3×3 → gradient magnitude G
|
||||||
|
* 4. Energy = 1 / (1 + G), clamped to [0, 1]
|
||||||
|
*/
|
||||||
|
export function buildEnergyMap(
|
||||||
|
bgrBuffer: Uint8Array,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
targetMaxSide = 256,
|
||||||
|
allowedMask?: Uint8Array | null,
|
||||||
|
): EnergyMap {
|
||||||
|
const imgLongSide = Math.max(cols, rows);
|
||||||
|
const scale = imgLongSide > targetMaxSide ? targetMaxSide / imgLongSide : 1;
|
||||||
|
const ew = Math.max(1, Math.floor(cols * scale));
|
||||||
|
const eh = Math.max(1, Math.floor(rows * scale));
|
||||||
|
const pixelCount = ew * eh;
|
||||||
|
|
||||||
|
// 1. Build grayscale at target resolution (nearest-neighbour downsample)
|
||||||
|
const gray = new Float32Array(pixelCount);
|
||||||
|
for (let gy = 0; gy < eh; gy++) {
|
||||||
|
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||||
|
const rowBase = sy * cols;
|
||||||
|
for (let gx = 0; gx < ew; gx++) {
|
||||||
|
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||||
|
const i = rowBase + sx;
|
||||||
|
const o = (i) * 3;
|
||||||
|
const val =
|
||||||
|
GRAY_R * bgrBuffer[o + 2] +
|
||||||
|
GRAY_G * bgrBuffer[o + 1] +
|
||||||
|
GRAY_B * bgrBuffer[o];
|
||||||
|
gray[gy * ew + gx] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sobel 3×3 → gradient magnitude
|
||||||
|
// X: [-1 0 1; -2 0 2; -1 0 1]
|
||||||
|
// Y: [-1 -2 -1; 0 0 0; 1 2 1]
|
||||||
|
const grad = new Float32Array(pixelCount);
|
||||||
|
let maxG = 1; // avoid division by zero
|
||||||
|
|
||||||
|
for (let gy = 1; gy < eh - 1; gy++) {
|
||||||
|
for (let gx = 1; gx < ew - 1; gx++) {
|
||||||
|
const idx = gy * ew + gx;
|
||||||
|
const a = gray[(gy - 1) * ew + (gx - 1)];
|
||||||
|
const b = gray[(gy - 1) * ew + gx];
|
||||||
|
const c = gray[(gy - 1) * ew + (gx + 1)];
|
||||||
|
const d = gray[gy * ew + (gx - 1)];
|
||||||
|
const e = gray[gy * ew + gx + 1];
|
||||||
|
const f = gray[(gy + 1) * ew + (gx - 1)];
|
||||||
|
const gv = gray[(gy + 1) * ew + gx];
|
||||||
|
const h = gray[(gy + 1) * ew + (gx + 1)];
|
||||||
|
|
||||||
|
const gxVal = -a + c - 2 * d + 2 * e - f + h;
|
||||||
|
const gyVal = -a - 2 * b - c + f + 2 * gv + h;
|
||||||
|
const mag = Math.sqrt(gxVal * gxVal + gyVal * gyVal);
|
||||||
|
grad[idx] = mag;
|
||||||
|
if (mag > maxG) maxG = mag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost wall-mask boundary so paths hug the semantic wall edge, not just texture.
|
||||||
|
if (allowedMask && allowedMask.length === cols * rows) {
|
||||||
|
for (let gy = 0; gy < eh; gy++) {
|
||||||
|
for (let gx = 0; gx < ew; gx++) {
|
||||||
|
const idx = gy * ew + gx;
|
||||||
|
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||||
|
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||||
|
if (!allowedMask[sy * cols + sx]) continue;
|
||||||
|
|
||||||
|
let onBoundary = sx === 0 || sy === 0 || sx === cols - 1 || sy === rows - 1;
|
||||||
|
if (!onBoundary) {
|
||||||
|
const n1 = allowedMask[sy * cols + (sx - 1)];
|
||||||
|
const n2 = allowedMask[sy * cols + (sx + 1)];
|
||||||
|
const n3 = allowedMask[(sy - 1) * cols + sx];
|
||||||
|
const n4 = allowedMask[(sy + 1) * cols + sx];
|
||||||
|
onBoundary = n1 === 0 || n2 === 0 || n3 === 0 || n4 === 0;
|
||||||
|
}
|
||||||
|
if (onBoundary) {
|
||||||
|
grad[idx] = maxG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Energy = 1 / (1 + normalized_gradient); amplify contrast so edges win over interior shortcuts.
|
||||||
|
const energy = new Float32Array(pixelCount);
|
||||||
|
const traversable =
|
||||||
|
allowedMask && allowedMask.length === cols * rows
|
||||||
|
? new Uint8Array(pixelCount)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
for (let gy = 0; gy < eh; gy++) {
|
||||||
|
for (let gx = 0; gx < ew; gx++) {
|
||||||
|
const idx = gy * ew + gx;
|
||||||
|
const sx = Math.min(cols - 1, Math.floor((gx * cols) / ew));
|
||||||
|
const sy = Math.min(rows - 1, Math.floor((gy * rows) / eh));
|
||||||
|
const allowed = !allowedMask || allowedMask[sy * cols + sx] > 0;
|
||||||
|
if (traversable) {
|
||||||
|
traversable[idx] = allowed ? 1 : 0;
|
||||||
|
}
|
||||||
|
energy[idx] = allowed ? 1.0 / (1.0 + 4.0 * (grad[idx] / maxG)) : 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { map: energy, w: ew, h: eh, scale, traversable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* findShortestPath — Dijkstra 8-connected
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/** 8-connected neighbour offsets (dx, dy) */
|
||||||
|
const NEIGHBOURS: [number, number][] = [
|
||||||
|
[-1, -1], [0, -1], [1, -1],
|
||||||
|
[-1, 0], /* */ [1, 0],
|
||||||
|
[-1, 1], [0, 1], [1, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Multiply energy cost so we can use integer priority keys. */
|
||||||
|
const COST_SCALE = 10000;
|
||||||
|
|
||||||
|
/** Binary min-heap for Dijkstra priority queue. */
|
||||||
|
class MinHeap {
|
||||||
|
private data: { idx: number; dist: number }[] = [];
|
||||||
|
|
||||||
|
push(idx: number, dist: number): void {
|
||||||
|
this.data.push({ idx, dist });
|
||||||
|
this.bubbleUp(this.data.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pop(): { idx: number; dist: number } | undefined {
|
||||||
|
if (this.data.length === 0) return undefined;
|
||||||
|
const top = this.data[0];
|
||||||
|
const last = this.data.pop()!;
|
||||||
|
if (this.data.length > 0) {
|
||||||
|
this.data[0] = last;
|
||||||
|
this.bubbleDown(0);
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bubbleUp(i: number): void {
|
||||||
|
while (i > 0) {
|
||||||
|
const parent = (i - 1) >> 1;
|
||||||
|
if (this.data[parent].dist <= this.data[i].dist) break;
|
||||||
|
[this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
|
||||||
|
i = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bubbleDown(i: number): void {
|
||||||
|
const n = this.data.length;
|
||||||
|
while (true) {
|
||||||
|
let smallest = i;
|
||||||
|
const left = 2 * i + 1;
|
||||||
|
const right = 2 * i + 2;
|
||||||
|
if (left < n && this.data[left].dist < this.data[smallest].dist) smallest = left;
|
||||||
|
if (right < n && this.data[right].dist < this.data[smallest].dist) smallest = right;
|
||||||
|
if (smallest === i) break;
|
||||||
|
[this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
|
||||||
|
i = smallest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentry value for "not visited" */
|
||||||
|
const DIST_INF = 0xffffffff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dijkstra shortest-path on 8-connected grid.
|
||||||
|
* Cost at each pixel = energy[pixel] * COST_SCALE (integer).
|
||||||
|
* Diagonal steps cost √2 × the neighbour's energy.
|
||||||
|
*
|
||||||
|
* Returns ordered path [start, …, end] in energy-map pixel space.
|
||||||
|
*/
|
||||||
|
export function findShortestPath(
|
||||||
|
energy: Float32Array,
|
||||||
|
energyW: number,
|
||||||
|
energyH: number,
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
ex: number,
|
||||||
|
ey: number,
|
||||||
|
traversable?: Uint8Array | null,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
// Clamp to valid range
|
||||||
|
const clampX = (v: number) => Math.max(1, Math.min(energyW - 2, Math.round(v)));
|
||||||
|
const clampY = (v: number) => Math.max(1, Math.min(energyH - 2, Math.round(v)));
|
||||||
|
|
||||||
|
const startX = clampX(sx);
|
||||||
|
const startY = clampY(sy);
|
||||||
|
const endX = clampX(ex);
|
||||||
|
const endY = clampY(ey);
|
||||||
|
|
||||||
|
const pixelCount = energyW * energyH;
|
||||||
|
const startIdx = startY * energyW + startX;
|
||||||
|
|
||||||
|
// Distance array (init to infinity)
|
||||||
|
const dist = new Uint32Array(pixelCount);
|
||||||
|
dist.fill(DIST_INF);
|
||||||
|
dist[startIdx] = 0;
|
||||||
|
|
||||||
|
// Previous node for path reconstruction
|
||||||
|
const prev = new Int32Array(pixelCount);
|
||||||
|
prev.fill(-1);
|
||||||
|
|
||||||
|
const heap = new MinHeap();
|
||||||
|
heap.push(startIdx, 0);
|
||||||
|
|
||||||
|
while (heap.length > 0) {
|
||||||
|
const node = heap.pop()!;
|
||||||
|
const u = node.idx;
|
||||||
|
const d = node.dist;
|
||||||
|
if (d > dist[u]) continue;
|
||||||
|
|
||||||
|
const ux = u % energyW;
|
||||||
|
const uy = Math.floor(u / energyW);
|
||||||
|
|
||||||
|
if (ux === endX && uy === endY) {
|
||||||
|
const path: { x: number; y: number }[] = [];
|
||||||
|
let cur = u;
|
||||||
|
while (cur >= 0) {
|
||||||
|
path.push({ x: cur % energyW, y: Math.floor(cur / energyW) });
|
||||||
|
cur = prev[cur];
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dx, dy] of NEIGHBOURS) {
|
||||||
|
const nx = ux + dx;
|
||||||
|
const ny = uy + dy;
|
||||||
|
if (nx < 0 || nx >= energyW || ny < 0 || ny >= energyH) continue;
|
||||||
|
const v = ny * energyW + nx;
|
||||||
|
if (traversable && traversable[v] === 0) continue;
|
||||||
|
|
||||||
|
const isDiagonal = dx !== 0 && dy !== 0;
|
||||||
|
const stepCost = isDiagonal
|
||||||
|
? Math.round(energy[v] * COST_SCALE * 1.4142)
|
||||||
|
: Math.round(energy[v] * COST_SCALE);
|
||||||
|
|
||||||
|
const alt = d + stepCost;
|
||||||
|
if (alt < dist[v]) {
|
||||||
|
dist[v] = alt;
|
||||||
|
prev[v] = u;
|
||||||
|
heap.push(v, alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No path found — return straight line
|
||||||
|
return [{ x: startX, y: startY }, { x: endX, y: endY }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* extractCornerPoints — Douglas-Peucker simplification
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Douglas-Peucker simplification. Keeps points where the perpendicular
|
||||||
|
* distance from the line segment exceeds epsilon.
|
||||||
|
*
|
||||||
|
* After DP, also enforces a minimum distance between consecutive anchors
|
||||||
|
* to avoid overly dense clusters.
|
||||||
|
*/
|
||||||
|
export function extractCornerPoints(
|
||||||
|
path: { x: number; y: number }[],
|
||||||
|
minDistance = 8,
|
||||||
|
epsilon = 2.0,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
if (path.length <= 2) return [...path];
|
||||||
|
|
||||||
|
const keep = new Uint8Array(path.length);
|
||||||
|
keep[0] = 1;
|
||||||
|
keep[path.length - 1] = 1;
|
||||||
|
|
||||||
|
function recurse(s: number, e: number) {
|
||||||
|
if (e - s <= 1) return;
|
||||||
|
|
||||||
|
const ax = path[s].x;
|
||||||
|
const ay = path[s].y;
|
||||||
|
const bx = path[e].x;
|
||||||
|
const by = path[e].y;
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
let maxDist = 0;
|
||||||
|
let maxIdx = s;
|
||||||
|
|
||||||
|
for (let i = s + 1; i < e; i++) {
|
||||||
|
let dist: number;
|
||||||
|
if (lenSq === 0) {
|
||||||
|
dist = Math.hypot(path[i].x - ax, path[i].y - ay);
|
||||||
|
} else {
|
||||||
|
const t = Math.max(0, Math.min(1,
|
||||||
|
((path[i].x - ax) * dx + (path[i].y - ay) * dy) / lenSq,
|
||||||
|
));
|
||||||
|
const px = ax + t * dx;
|
||||||
|
const py = ay + t * dy;
|
||||||
|
dist = Math.hypot(path[i].x - px, path[i].y - py);
|
||||||
|
}
|
||||||
|
if (dist > maxDist) {
|
||||||
|
maxDist = dist;
|
||||||
|
maxIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDist > epsilon) {
|
||||||
|
keep[maxIdx] = 1;
|
||||||
|
recurse(s, maxIdx);
|
||||||
|
recurse(maxIdx, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recurse(0, path.length - 1);
|
||||||
|
|
||||||
|
// Collect kept points with minDistance filter
|
||||||
|
const result: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
if (!keep[i]) continue;
|
||||||
|
if (result.length > 0) {
|
||||||
|
const last = result[result.length - 1];
|
||||||
|
const dist = Math.hypot(path[i].x - last.x, path[i].y - last.y);
|
||||||
|
if (dist < minDistance) continue;
|
||||||
|
}
|
||||||
|
result.push({ x: path[i].x, y: path[i].y });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* upscalePath
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/** Map normalized image coords (0..1) to energy-map pixel coords. */
|
||||||
|
export function normToEnergyPoint(
|
||||||
|
normX: number,
|
||||||
|
normY: number,
|
||||||
|
em: EnergyMap,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: Math.min(em.w - 1, Math.max(0, normX * em.w)),
|
||||||
|
y: Math.min(em.h - 1, Math.max(0, normY * em.h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map energy-map pixel coords back to normalized image coords. */
|
||||||
|
export function energyPointsToNorm(
|
||||||
|
points: { x: number; y: number }[],
|
||||||
|
em: EnergyMap,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
return points.map(p => ({
|
||||||
|
x: Math.min(1, Math.max(0, p.x / em.w)),
|
||||||
|
y: Math.min(1, Math.max(0, p.y / em.h)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map energy-map pixel coords back to original image coords. */
|
||||||
|
export function upscalePath(
|
||||||
|
points: { x: number; y: number }[],
|
||||||
|
scale: number,
|
||||||
|
originW: number,
|
||||||
|
originH: number,
|
||||||
|
): { x: number; y: number }[] {
|
||||||
|
return points.map(p => ({
|
||||||
|
x: Math.min(originW - 1, Math.max(0, Math.round(p.x / scale))),
|
||||||
|
y: Math.min(originH - 1, Math.max(0, Math.round(p.y / scale))),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -277,7 +277,7 @@ function filterOutlineLoops(
|
|||||||
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
|
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
function floodFillComponent(
|
export function floodFillComponent(
|
||||||
binary: Uint8Array,
|
binary: Uint8Array,
|
||||||
cols: number,
|
cols: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
|
|||||||
@ -137,6 +137,13 @@ export const DEFAULT_MASK_CONFIG: Required<
|
|||||||
splitWallsColorDistSq: 1400,
|
splitWallsColorDistSq: 1400,
|
||||||
splitWallsChromaBlurRadius: 5,
|
splitWallsChromaBlurRadius: 5,
|
||||||
splitWallsNeutralChromaMax: 14,
|
splitWallsNeutralChromaMax: 14,
|
||||||
|
splitWallsEdgeBarrierThreshold: 160,
|
||||||
|
splitWallsCloseMaskRadius:4,
|
||||||
|
manualSplitWalls: false,
|
||||||
|
manualSplitWallsMaxCount: 8,
|
||||||
|
manualSplitWallsGapAbsorbDilatePx: 5,
|
||||||
|
magneticLasso: false,
|
||||||
|
activeContourRefine: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResolvedMaskSegmentRuntime = {
|
export type ResolvedMaskSegmentRuntime = {
|
||||||
@ -209,6 +216,24 @@ export function mergeMaskConfig(
|
|||||||
splitWallsNeutralChromaMax:
|
splitWallsNeutralChromaMax:
|
||||||
partial.splitWallsNeutralChromaMax ??
|
partial.splitWallsNeutralChromaMax ??
|
||||||
DEFAULT_MASK_CONFIG.splitWallsNeutralChromaMax,
|
DEFAULT_MASK_CONFIG.splitWallsNeutralChromaMax,
|
||||||
|
splitWallsEdgeBarrierThreshold:
|
||||||
|
partial.splitWallsEdgeBarrierThreshold ??
|
||||||
|
DEFAULT_MASK_CONFIG.splitWallsEdgeBarrierThreshold,
|
||||||
|
splitWallsCloseMaskRadius:
|
||||||
|
partial.splitWallsCloseMaskRadius ??
|
||||||
|
DEFAULT_MASK_CONFIG.splitWallsCloseMaskRadius,
|
||||||
|
manualSplitWalls:
|
||||||
|
partial.manualSplitWalls ?? DEFAULT_MASK_CONFIG.manualSplitWalls,
|
||||||
|
manualSplitWallsMaxCount:
|
||||||
|
partial.manualSplitWallsMaxCount ??
|
||||||
|
DEFAULT_MASK_CONFIG.manualSplitWallsMaxCount,
|
||||||
|
manualSplitWallsGapAbsorbDilatePx:
|
||||||
|
partial.manualSplitWallsGapAbsorbDilatePx ??
|
||||||
|
DEFAULT_MASK_CONFIG.manualSplitWallsGapAbsorbDilatePx,
|
||||||
|
magneticLasso:
|
||||||
|
partial.magneticLasso ?? DEFAULT_MASK_CONFIG.magneticLasso,
|
||||||
|
activeContourRefine:
|
||||||
|
partial.activeContourRefine ?? DEFAULT_MASK_CONFIG.activeContourRefine,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -414,6 +414,10 @@ export type RegionMaskData = {
|
|||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
wallSubLabels?: Uint8Array;
|
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) */
|
/** 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,
|
maskData: RegionMaskData,
|
||||||
maxLongSide: number,
|
maxLongSide: number,
|
||||||
): RegionMaskData {
|
): RegionMaskData {
|
||||||
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
|
const { labels, baseboardBinary, cols, rows, wallSubLabels, indexToName, wallSemanticIdx } =
|
||||||
|
maskData;
|
||||||
const longSide = Math.max(cols, rows);
|
const longSide = Math.max(cols, rows);
|
||||||
if (longSide <= maxLongSide) {
|
if (longSide <= maxLongSide) {
|
||||||
return maskData;
|
return maskData;
|
||||||
@ -460,6 +465,8 @@ export function downsampleMaskDataForPaths(
|
|||||||
cols: dstCols,
|
cols: dstCols,
|
||||||
rows: dstRows,
|
rows: dstRows,
|
||||||
wallSubLabels: outWallSub,
|
wallSubLabels: outWallSub,
|
||||||
|
indexToName,
|
||||||
|
wallSemanticIdx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,15 +16,88 @@ function maskCfg() {
|
|||||||
return getMaskSegmentRuntimeConfig().mask;
|
return getMaskSegmentRuntimeConfig().mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bboxToPolygon(bbox: SegmentRegion['bbox']): { x: number; y: number }[] {
|
type Point = { x: number; y: number };
|
||||||
return [
|
|
||||||
{ x: bbox.x, y: bbox.y },
|
/** Moore-neighbor boundary tracer on a binary mask component. */
|
||||||
{ x: bbox.x + bbox.w, y: bbox.y },
|
function traceMaskPolygon(
|
||||||
{ x: bbox.x + bbox.w, y: bbox.y + bbox.h },
|
mask: Uint8Array,
|
||||||
{ x: bbox.x, y: bbox.y + bbox.h },
|
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(
|
function computeLabChromaMaps(
|
||||||
originBgr: Uint8Array,
|
originBgr: Uint8Array,
|
||||||
cols: number,
|
cols: number,
|
||||||
@ -42,6 +115,79 @@ function computeLabChromaMaps(
|
|||||||
return { aMap, bMap };
|
return { aMap, bMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-channel BGR Sobel gradient magnitude. max(B, G, R) for sensitivity to color edges. */
|
||||||
|
function buildEdgeBarrierMask(
|
||||||
|
bgr: Uint8Array,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
wallIdx: number,
|
||||||
|
labels: Uint8Array,
|
||||||
|
baseboardBinary: Uint8Array,
|
||||||
|
threshold: number,
|
||||||
|
): Uint8Array {
|
||||||
|
const n = cols * rows;
|
||||||
|
const barriers = new Uint8Array(n);
|
||||||
|
if (threshold <= 0) return barriers;
|
||||||
|
|
||||||
|
// Per-channel Sobel 3x3 → take max raw gradient.
|
||||||
|
// Raw range is [0, ~1442] for 8‑bit BGR. No normalization — a single
|
||||||
|
// extremely strong edge (e.g. window frame) would compress all other
|
||||||
|
// edges if we normalized relative to maxG.
|
||||||
|
const C = cols;
|
||||||
|
|
||||||
|
for (let y = 1; y < rows - 1; y++) {
|
||||||
|
const r0 = (y - 1) * C;
|
||||||
|
const r1 = y * C;
|
||||||
|
const r2 = (y + 1) * C;
|
||||||
|
for (let x = 1; x < C - 1; x++) {
|
||||||
|
const i = r1 + x;
|
||||||
|
if (labels[i] !== wallIdx || baseboardBinary[i]) continue;
|
||||||
|
|
||||||
|
const a0 = r0 + (x - 1), a1 = r0 + x, a2 = r0 + (x + 1);
|
||||||
|
const b0 = r1 + (x - 1), b2 = r1 + (x + 1);
|
||||||
|
const c0 = r2 + (x - 1), c1 = r2 + x, c2 = r2 + (x + 1);
|
||||||
|
|
||||||
|
let best = 0;
|
||||||
|
for (let ch = 0; ch < 3; ch++) {
|
||||||
|
const a = bgr[a0 * 3 + ch];
|
||||||
|
const b = bgr[a1 * 3 + ch];
|
||||||
|
const c = bgr[a2 * 3 + ch];
|
||||||
|
const d = bgr[b0 * 3 + ch];
|
||||||
|
const e = bgr[b2 * 3 + ch];
|
||||||
|
const f = bgr[c0 * 3 + ch];
|
||||||
|
const gv = bgr[c1 * 3 + ch];
|
||||||
|
const h = bgr[c2 * 3 + ch];
|
||||||
|
const gx = -a + c - 2 * d + 2 * e - f + h;
|
||||||
|
const gy = -a - 2 * b - c + f + 2 * gv + h;
|
||||||
|
const mag = Math.sqrt(gx * gx + gy * gy);
|
||||||
|
if (mag > best) best = mag;
|
||||||
|
}
|
||||||
|
if (best > threshold) barriers[i] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dilate 1px to widen the barrier slightly
|
||||||
|
return dilateBinary1px(barriers, cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dilateBinary1px(src: Uint8Array, cols: number, rows: number): Uint8Array {
|
||||||
|
const dst = new Uint8Array(src);
|
||||||
|
for (let y = 1; y < rows - 1; y++) {
|
||||||
|
const rc = y * cols;
|
||||||
|
for (let x = 1; x < cols - 1; x++) {
|
||||||
|
const i = rc + x;
|
||||||
|
if (src[i]) continue;
|
||||||
|
if (
|
||||||
|
src[i - 1] || src[i + 1] ||
|
||||||
|
src[(y - 1) * cols + x] || src[(y + 1) * cols + x]
|
||||||
|
) {
|
||||||
|
dst[i] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
function chromaMag(a: number, b: number): number {
|
function chromaMag(a: number, b: number): number {
|
||||||
const da = a - 128;
|
const da = a - 128;
|
||||||
const db = b - 128;
|
const db = b - 128;
|
||||||
@ -109,6 +255,81 @@ function isWallPixel(
|
|||||||
return labels[i] === wallIdx;
|
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;
|
* 4-connected component growth: compares against component chroma mean to avoid chain bridging;
|
||||||
* forces separation at neutral/colored wall boundaries.
|
* forces separation at neutral/colored wall boundaries.
|
||||||
@ -119,6 +340,7 @@ function labelWallComponents(
|
|||||||
wallIdx: number,
|
wallIdx: number,
|
||||||
aMap: Uint8Array,
|
aMap: Uint8Array,
|
||||||
bMap: Uint8Array,
|
bMap: Uint8Array,
|
||||||
|
barrierMask: Uint8Array,
|
||||||
cols: number,
|
cols: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
distSqThreshold: number,
|
distSqThreshold: number,
|
||||||
@ -164,6 +386,7 @@ function labelWallComponents(
|
|||||||
const nx = ni % cols;
|
const nx = ni % cols;
|
||||||
if (Math.abs(nx - cx) > 1) continue;
|
if (Math.abs(nx - cx) > 1) continue;
|
||||||
if (!isWallPixel(labels, baseboardBinary, wallIdx, ni)) continue;
|
if (!isWallPixel(labels, baseboardBinary, wallIdx, ni)) continue;
|
||||||
|
if (barrierMask[ni]) continue;
|
||||||
if (compLabels[ni] >= 0) continue;
|
if (compLabels[ni] >= 0) continue;
|
||||||
|
|
||||||
const na = aMap[ni];
|
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;
|
const pixelCount = cols * rows;
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
const c = compLabels[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(
|
function relabelComponentsContiguous(
|
||||||
compLabels: Int32Array,
|
compLabels: Int32Array,
|
||||||
cols: number,
|
cols: number,
|
||||||
@ -413,7 +727,7 @@ function relabelComponentsContiguous(
|
|||||||
return { labels: out, compCount, stats };
|
return { labels: out, compCount, stats };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPickMapAfterWallSplit(
|
export function buildPickMapAfterWallSplit(
|
||||||
labels: Uint8Array,
|
labels: Uint8Array,
|
||||||
baseboardBinary: Uint8Array,
|
baseboardBinary: Uint8Array,
|
||||||
wallIdx: number,
|
wallIdx: number,
|
||||||
@ -437,12 +751,15 @@ function buildPickMapAfterWallSplit(
|
|||||||
continue;
|
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 wallName = `wall-${wallSubLabels[i] + 1}`;
|
||||||
const regionId = nameToId.get(wallName);
|
const regionId = nameToId.get(wallName);
|
||||||
if (regionId !== undefined) {
|
if (regionId !== undefined) {
|
||||||
pick[i] = regionId + 1;
|
pick[i] = regionId + 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Unpartitioned wall pixels stay 0 (no parent "wall" region after manual split).
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,7 +776,50 @@ function buildPickMapAfterWallSplit(
|
|||||||
return pick;
|
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,
|
pick: Uint8Array,
|
||||||
cols: number,
|
cols: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
@ -505,6 +865,130 @@ function dilatePickBuffer1px(
|
|||||||
return dst;
|
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
|
* 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;
|
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 { 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 distSqThreshold = cfg.splitWallsColorDistSq;
|
||||||
const neutralChromaMax = cfg.splitWallsNeutralChromaMax;
|
const neutralChromaMax = cfg.splitWallsNeutralChromaMax;
|
||||||
const minAreaFloor = Math.max(
|
const minAreaFloor = Math.max(
|
||||||
@ -545,11 +1042,12 @@ export function splitWallRegionsByTexture(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { compLabels: rawCompLabels, compCount: rawCount } = labelWallComponents(
|
const { compLabels: rawCompLabels, compCount: rawCount } = labelWallComponents(
|
||||||
labels,
|
bfsLabels,
|
||||||
baseboardBinary,
|
bfsBaseboard,
|
||||||
wallIdx,
|
wallIdx,
|
||||||
rawA,
|
rawA,
|
||||||
rawB,
|
rawB,
|
||||||
|
barrierMask,
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
distSqThreshold,
|
distSqThreshold,
|
||||||
@ -573,8 +1071,18 @@ export function splitWallRegionsByTexture(
|
|||||||
neutralChromaMax,
|
neutralChromaMax,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { labels: finalCompLabels, compCount, stats: finalStats } =
|
let finalCompLabels: Int32Array;
|
||||||
relabelComponentsContiguous(rawCompLabels, cols, rows);
|
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) {
|
if (compCount === 0) {
|
||||||
return result;
|
return result;
|
||||||
@ -607,18 +1115,37 @@ export function splitWallRegionsByTexture(
|
|||||||
const wallHex = wallRef?.hex ?? wallRegion.hex;
|
const wallHex = wallRef?.hex ?? wallRegion.hex;
|
||||||
const wallColor = wallRef?.bgr ?? wallRegion.color;
|
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 nonWallRegions = regions.filter(reg => reg.name !== 'wall');
|
||||||
const wallSubRegions: SegmentRegion[] = ranked.map((s, rank) => {
|
const wallSubRegions: SegmentRegion[] = ranked.map((s, rank) => {
|
||||||
const bbox = s.bbox;
|
const mask = compMasks[rank];
|
||||||
const poly = bboxToPolygon(bbox);
|
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 {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: `wall-${rank + 1}`,
|
name: `wall-${rank + 1}`,
|
||||||
hex: wallHex,
|
hex: wallHex,
|
||||||
color: { ...wallColor },
|
color: { ...wallColor },
|
||||||
polygons: [poly],
|
polygons: [fallback],
|
||||||
outlinePolygons: [poly],
|
outlinePolygons: [fallback],
|
||||||
bbox,
|
bbox: s.bbox,
|
||||||
area: s.area,
|
area: s.area,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user