From 3a3f07628dcf9ed590bf82818f4c6cf02b8787ba Mon Sep 17 00:00:00 2001 From: a1518 Date: Mon, 6 Jul 2026 20:23:57 -0700 Subject: [PATCH] feat: add manual lasso wall splitting with magnetic edge-snapping and active contour refinement - New magneticLasso module: Sobel energy map + Dijkstra shortest-path + Douglas-Peucker - New activeContour module: greedy snake + balloon force for polygon-to-edge refinement - wallTextureSplit: edge barrier mask, morphological mask hole closing, Moore boundary tracing, simplified polygon contours, manual pick map patching, gap absorption - MaskSegmentCanvas: full lasso gesture pipeline (tap vertices, drag, magnetic paths, close polygon, endLasso/cancelLasso/deleteLasso) - New maskConfig options: splitWallsEdgeBarrierThreshold, splitWallsCloseMaskRadius, manualSplitWalls, manualSplitWallsMaxCount, manualSplitWallsGapAbsorbDilatePx, magneticLasso, activeContourRefine - New ref methods: startLasso, endLasso, cancelLasso, getManualRegions, deleteLasso - New exported types: LassoPolygon, ManualWallPartition - RegionMaskData carries indexToName and wallSemanticIdx through downsample - Simplify README to point to documentation site - Update documentation site (EN + ZH-CN) with all new APIs and interaction guide - Example app: lasso mode toggles and operation buttons Co-authored-by: Cursor --- README.md | 990 +---------- __tests__/wallTextureSplit.test.ts | 278 +++- dist/components/MaskSegmentCanvas.js | 2 +- dist/components/MaskSegmentCanvas.types.d.ts | 67 + dist/components/MaskSegmentCanvas.types.js | 2 +- dist/index.d.ts | 2 +- dist/index.js | 2 +- dist/utils/activeContour.d.ts | 46 + dist/utils/activeContour.js | 1 + dist/utils/magneticLasso.d.ts | 112 ++ dist/utils/magneticLasso.js | 1 + dist/utils/maskOutlinePaths.d.ts | 1 + dist/utils/maskOutlinePaths.js | 2 +- dist/utils/maskSegmentRuntime.js | 2 +- dist/utils/maskSegmentation.d.ts | 4 + dist/utils/maskSegmentation.js | 2 +- dist/utils/wallTextureSplit.d.ts | 18 + dist/utils/wallTextureSplit.js | 2 +- docs/docs/api/index.md | 3 + docs/docs/api/mask-config.md | 18 + docs/docs/api/ref-methods.md | 22 + docs/docs/interaction-guide.md | 24 + docs/docs/intro.md | 6 +- docs/docs/project-structure.md | 3 + .../current/api/index.md | 3 + .../current/api/mask-config.md | 18 + .../current/api/ref-methods.md | 22 + .../current/interaction-guide.md | 24 + .../current/intro.md | 6 +- .../current/project-structure.md | 3 + example/App.tsx | 183 +- package.json | 2 +- src/components/MaskSegmentCanvas.tsx | 1477 ++++++++++++++++- src/components/MaskSegmentCanvas.types.ts | 58 + src/index.ts | 2 + src/utils/activeContour.ts | 380 +++++ src/utils/magneticLasso.ts | 621 +++++++ src/utils/maskOutlinePaths.ts | 2 +- src/utils/maskSegmentRuntime.ts | 25 + src/utils/maskSegmentation.ts | 9 +- src/utils/wallTextureSplit.ts | 571 ++++++- 41 files changed, 4014 insertions(+), 1002 deletions(-) create mode 100644 dist/utils/activeContour.d.ts create mode 100644 dist/utils/activeContour.js create mode 100644 dist/utils/magneticLasso.d.ts create mode 100644 dist/utils/magneticLasso.js create mode 100644 src/utils/activeContour.ts create mode 100644 src/utils/magneticLasso.ts diff --git a/README.md b/README.md index 9399fe2..a7d215a 100644 --- a/README.md +++ b/README.md @@ -1,807 +1,82 @@ -[**๐Ÿ‡จ๐Ÿ‡ณ ไธญๆ–‡**](README.zh-CN.md) +[**๐Ÿ‡จ๐Ÿ‡ณ ไธญๆ–‡ๆ–‡ๆกฃ**](https://tonychan-hub.github.io/react-native-mask-segment-canvas/zh-CN/) --- # ๐ŸŽจ 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. +A React Native **0.79** interactive mask segmentation library combining **OpenCV** semantic layout + **Skia** GPU-accelerated texture painting. - ๐Ÿง  **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 -- ๐Ÿ‘† **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`). +- ๐Ÿ–Œ๏ธ **Skia RuntimeEffect (SkSL)**: single-pass LAB frequency-layer color blending +- โœ‚๏ธ **Skia Path**: dashed outline highlights per region +- ๐Ÿงฒ **Magnetic Lasso**: manual wall partitioning with edge-snapping + Active Contour refinement --- -## Table of Contents +## ๐Ÿ“– Full Documentation -- [Overview](#overview) -- [Requirements](#requirements) -- [Installation](#installation) - - [Peer Dependencies](#peer-dependencies) - - [Postinstall Setup](#postinstall-setup) - - [iOS / Android Native Dependencies](#ios--android-native-dependencies) - - [Metro Configuration](#metro-configuration) - - [Troubleshooting: Duplicate Module Errors](#troubleshooting-duplicate-module-errors) -- [Quick Start (Dev Demo)](#quick-start-dev-demo) -- [Basic Usage](#basic-usage) - - [Minimal Example](#minimal-example) - - [State Variables](#state-variables) - - [Choosing Configuration Values](#choosing-configuration-values) - - [watchState & UI Guidance](#watchstate--ui-guidance) -- [API Reference](#api-reference) - - [Imports](#imports) - - [Props: Image & Initialization](#props-image--initialization) - - [Props: Semantic Colors & Outline](#props-semantic-colors--outline) - - [Props: maskConfig](#props-maskconfig) - - [Props: pipelineConfig](#props-pipelineconfig) - - [Props: paintConfig](#props-paintconfig) - - [Props: interactionConfig](#props-interactionconfig) - - [Props: UI Controls & Styling](#props-ui-controls--styling) - - [Props: Callbacks](#props-callbacks) - - [Ref Methods](#ref-methods) - - [Storage Convention](#storage-convention) -- [Interaction Guide](#interaction-guide) -- [Integration Examples](#integration-examples) - - [Pre-warm PNG Cache (Recommended)](#pre-warm-png-cache-recommended) - - [Passing Local Paths from an API](#passing-local-paths-from-an-api) - - [Draft Recovery](#draft-recovery) - - [Custom Semantic Color Table](#custom-semantic-color-table) -- [Project Structure](#project-structure) -- [Dependencies](#dependencies) -- [Performance](#performance) - - [Measured Reference (Dev Env + PNG Pre-warming)](#measured-reference-dev-env-png-pre-warming) - - [Resolution vs pipelineConfig](#resolution-vs-pipelineconfig) - - [interactive Estimation (Default Pipeline)](#interactive-estimation-default-pipeline) - - [Device Tier (1080p, Default Pipeline)](#device-tier-1080p-default-pipeline) - - [Impact of Raising maxImageLongSide](#impact-of-raising-maximagelongside) - - [Optimization Tips](#optimization-tips) -- [Notes](#notes) -- [Troubleshooting](#troubleshooting) +**All API references, configuration guides, integration examples, and troubleshooting are maintained on the documentation site:** + +๐Ÿ‘‰ **[https://tonychan-hub.github.io/react-native-mask-segment-canvas/](https://tonychan-hub.github.io/react-native-mask-segment-canvas/)** + +| Section | Description | +|---|---| +| [Overview](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/intro) | Architecture & pipeline overview | +| [Installation](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/installation) | Peer deps, postinstall, Metro config | +| [Basic Usage](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/basic-usage) | Minimal example, state variables, watchState | +| [API Reference](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/api) | Props, ref methods, types, storage convention | +| [Performance](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/performance) | Benchmarks, optimization tips, pipeline config tuning | +| [Troubleshooting](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/troubleshooting) | Common issues & fixes | --- -## ๐Ÿ”ญ 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. ๐Ÿ‘† **Interactive** โ€” users select a brush color and tap regions to paint; paint layers preserve the underlying texture -6. ๐Ÿ’พ **Save** the composited result as PNG; export a JSON session for draft recovery - -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) - ---- - -## ๐Ÿ“ฆ Installation - -### ๐Ÿ“ฆ Peer Dependencies - -Install these in your host project (versions should match your host RN version): +## ๐Ÿ“ฆ Quick Install ```bash -npm install @shopify/react-native-skia react-native-reanimated react-native-fast-opencv react-native-fs buffer upng-js -# If using showDebugPickers (photo library picker) -npm install react-native-image-picker +npm install react-native-mask-segment-canvas ``` -### ๐Ÿ› ๏ธ Postinstall Setup +**Required peer dependencies:** -This library relies on `patch-package` to patch `react-native-fast-opencv`. Your host `package.json` must include: +```bash +npm install @shopify/react-native-skia react-native-reanimated \ + react-native-fast-opencv react-native-fs buffer upng-js +``` + +Your host `package.json` must include `patch-package` in `postinstall`: ```json { - "scripts": { - "postinstall": "patch-package" - }, - "devDependencies": { - "patch-package": "^8.0.1" - } + "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 `example/README.md` for a detailed checklist and template. +See the [Installation Guide](https://tonychan-hub.github.io/react-native-mask-segment-canvas/docs/installation) for full Metro configuration and troubleshooting duplicate module errors. --- -## ๐Ÿš€ Quick Start (Dev Demo) +## ๐Ÿงช Example Project -The root `App.tsx` is a full self-test demo that imports directly from `./src`. +The [`example/`](example/) directory contains a complete consumer-side integration demo using only the public API โ€” ideal as a template for your own project. ```bash -cd MaskSegmentApp - +cd example npm install - -cd ios && bundle exec pod install && cd .. - +cd ios && 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. - ---- - -## ๐Ÿ’ก 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(null); - - const [imagePaths, setImagePaths] = useState(null); - const [pathsError, setPathsError] = useState(''); - const [watchState, setWatchState] = useState(''); - const [errorMessage, setErrorMessage] = useState(''); - const [sessionDraft] = useState(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'; - - // Example: download images, then pre-warm PNG decode cache - 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 {pathsError}; - } - - if (!imagePaths) { - return ( - - - Waiting for origin and mask images... - - ); - } - - return ( - - {isCanvasLoading ? Loading: {watchState} : null} - {watchState === 'interactive' ? ( - Paintable (outlines loading...) - ) : null} - {isOutlineReady ? Ready : null} - {errorMessage ? {errorMessage} : null} - - { - 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'); - }} - /> - - ); -} -``` - -### ๐Ÿ“Š 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 (no need to wait for outline paths) -const canOperate = - watchState === 'interactive' || watchState === 'mask_paths_ready'; - -// Carousel dashed outlines are fully ready (optional โ€” can dismiss "outlines preparing" hint) -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. - ---- - -## ๐Ÿ“– 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, - 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` | -| 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: 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` | no | โ€” | **Optional**. Accompanying brush config for `initialPaintColor`; passed back via `onPaintCallback` on successful paint | - - -### ๐ŸŽจ 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`). - -### ๐Ÿงฉ 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 | - - -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. - -### ๐Ÿ”ฌ 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 | - - -### ๐Ÿ–Œ๏ธ 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 | - - -### ๐Ÿ‘† 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 | - - -> Full default constants: `DEFAULT_MASK_CONFIG`, `DEFAULT_PIPELINE_CONFIG`, `DEFAULT_PAINT_CONFIG`, `DEFAULT_INTERACTION_CONFIG` (exported from the package entry). - -### ๐ŸŽ›๏ธ 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` (zh) | Undo button label | -| `compareButtonText` | `string` | `Compare` (zh) | Enter compare mode label | -| `compareExitButtonText` | `string` | `Exit Compare` (zh) | Exit compare mode label | -| `renderUndoButton` | `(props) => ReactNode` | โ€” | Custom undo button renderer | -| `renderCompareButton` | `(props) => ReactNode` | โ€” | Custom compare button renderer | - - -### ๐Ÿ“ž 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; // 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`. - -### ๐Ÿ”ง 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` | 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` | Clear PNG cache and re-segment | -| `getRegions` | `() => SegmentRegion[]` | Snapshot of current region list | -| `getPaintedRegions` | `() => PaintedRegionRecord[]` | Snapshot of current paint records | - - -`SavePaintResult`: `{ filePath, width, height, paintedCount, previewPath? }` - -Code examples: - -```tsx -const ref = useRef(null); - -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(); -``` - -> `save` depends on the working buffer and pickMap being ready (typically after `interactive`); throws `'Image not ready, cannot save'` if not ready. - -### ๐Ÿ’พ 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; - savedAt: number; -} ``` --- -## ๐ŸŽฎ Interaction Guide +## ๐Ÿ—๏ธ Dev Demo -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. +The root `App.tsx` is a self-test demo that imports directly from `./src`: ---- - -## ๐Ÿงฉ Integration Examples - -### ๐Ÿ”ฅ Pre-warm PNG Cache (Recommended) - -```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 }); -} -``` - -### ๐ŸŒ Passing Local Paths from an API - -```tsx - { - if (state === 'interactive') hideBlockingLoader(); - if (state === 'mask_paths_ready') hideOutlineHint(); - }} -/> -``` - -### ๐Ÿ’พ Draft Recovery - -```tsx -const draft = JSON.parse(mmkv.getString('paint_draft')); - - -``` - -### ๐ŸŽจ Custom Semantic Color Table - -```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 } }, - // ... -]; - - +```bash +npm install +cd ios && pod install && cd .. +npm run ios # or `npm run android` ``` --- @@ -809,181 +84,12 @@ const gymColors: MaskSemanticColor[] = [ ## ๐Ÿ“ 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 -โ”‚ โ””โ”€โ”€ ... -โ”œโ”€โ”€ 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/ +MaskSegmentApp/ +โ”œโ”€โ”€ src/ # Library source โ†’ published to npm +โ”‚ โ”œโ”€โ”€ index.ts +โ”‚ โ””โ”€โ”€ components/ / utils/ +โ”œโ”€โ”€ docs/ # Docusaurus documentation site +โ”œโ”€โ”€ example/ # Consumer-side integration demo +โ”œโ”€โ”€ App.tsx # Dev self-test (imports from ./src) +โ””โ”€โ”€ patches/ # patch-package patches for react-native-fast-opencv ``` - ---- - -## ๐Ÿ“š 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 | -| `react-native-reanimated` | Skia animation dependency | -| `react-native-safe-area-context` | Safe area insets | - - ---- - -## โšก 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 -``` - -1. โฑ๏ธ **Loading timing**: Dismiss the blocking loader at `interactive`; optionally listen for `mask_paths_ready` for "outlines preparing" hints. -2. ๐Ÿ–ผ๏ธ **Large images / low-end devices**: Keep default `maxImageLongSide: 720`; optionally lower `paintFreqMaxLongSide` to **360**. -3. ๐Ÿ“ท **4K assets**: Downsample on the host side before passing in, or accept ~0.8โ€“1.5s `interactive` (with pre-warm). -4. ๐Ÿ” **Observability**: Watch Metro logs for `[MaskSegment]`, `[โฑ ...]` prefixes and `onWatch` `durationMs`. - ---- - -## ๐Ÿ“ 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. - ---- - -## ๐Ÿ”ง 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 - diff --git a/__tests__/wallTextureSplit.test.ts b/__tests__/wallTextureSplit.test.ts index 5b2f34e..4021f2c 100644 --- a/__tests__/wallTextureSplit.test.ts +++ b/__tests__/wallTextureSplit.test.ts @@ -8,7 +8,14 @@ import { setMaskSegmentRuntimeConfig, } from '../src/utils/maskSegmentRuntime'; import type { SegmentMaskResult } from '../src/utils/maskSegmentation'; -import { splitWallRegionsByTexture } from '../src/utils/wallTextureSplit'; +import { + buildPickMapAfterWallSplit, + dilatePickBuffer1px, + patchPickMapForManualWallSplit, + splitWallRegionsByTexture, + absorbSmallWallGapsForLassoPolygons, + WALL_SUB_LABEL_NONE, +} from '../src/utils/wallTextureSplit'; const WALL_IDX = 3; const IGNORE = 255; @@ -180,6 +187,196 @@ test('same-color wall with lighting gradient stays one region', () => { expect(wallSubs[0].name).toBe('wall-1'); }); +test('manual wall split pick map keeps ceiling and wall sub-regions paintable', () => { + const cols = 20; + const rows = 10; + const CEILING_IDX = 1; + const WALL_IDX = 3; + const pixelCount = cols * rows; + const labels = new Uint8Array(pixelCount); + labels.fill(CEILING_IDX); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols; x++) { + labels[y * cols + x] = WALL_IDX; + } + } + const baseboardBinary = new Uint8Array(pixelCount); + const wallSubLabels = new Uint8Array(pixelCount); + wallSubLabels.fill(WALL_SUB_LABEL_NONE); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols / 2; x++) { + wallSubLabels[y * cols + x] = 0; + } + for (let x = cols / 2; x < cols; x++) { + wallSubLabels[y * cols + x] = 1; + } + } + + const indexToName = [ + 'door', + 'ceiling', + 'cabinet', + 'wall', + 'baseboard', + 'windowFrame', + 'garageDoor', + 'roof', + 'eave', + ]; + const mergedRegions = [ + { id: 0, name: 'ceiling', area: 100 }, + { id: 1, name: 'wall-1', area: 50 }, + { id: 2, name: 'wall-2', area: 50 }, + ]; + const nameToId = new Map(mergedRegions.map(r => [r.name, r.id])); + const pickRaw = buildPickMapAfterWallSplit( + labels, + baseboardBinary, + WALL_IDX, + wallSubLabels, + indexToName, + nameToId, + cols, + rows, + ); + const pick = dilatePickBuffer1px(pickRaw, cols, rows); + + expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0); + expect(pickRegionIdAt(pick, cols, 3, 7)).toBe(1); + expect(pickRegionIdAt(pick, cols, 15, 7)).toBe(2); +}); + +test('manual wall split keeps non-wall region IDs stable', () => { + const cols = 20; + const rows = 10; + const CEILING_IDX = 1; + const WALL_IDX = 3; + const WINDOW_IDX = 5; + const pixelCount = cols * rows; + const labels = new Uint8Array(pixelCount); + labels.fill(CEILING_IDX); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols; x++) { + labels[y * cols + x] = WALL_IDX; + } + } + labels[2 * cols + 18] = WINDOW_IDX; + + const nonWallRegions = [ + { id: 0, name: 'ceiling', area: 100 }, + { id: 2, name: 'windowFrame', area: 20 }, + ]; + const maxNonWallId = Math.max(...nonWallRegions.map(r => r.id)); + const wallSubRegions = [ + { id: maxNonWallId + 1, name: 'wall-1', area: 50 }, + { id: maxNonWallId + 2, name: 'wall-2', area: 40 }, + ]; + const mergedRegions = [...nonWallRegions, ...wallSubRegions]; + + expect(mergedRegions.find(r => r.name === 'ceiling')?.id).toBe(0); + expect(mergedRegions.find(r => r.name === 'windowFrame')?.id).toBe(2); + expect(mergedRegions.find(r => r.name === 'wall-1')?.id).toBe(3); + expect(mergedRegions.find(r => r.name === 'wall-2')?.id).toBe(4); + + const indexToName = [ + 'door', + 'ceiling', + 'cabinet', + 'wall', + 'baseboard', + 'windowFrame', + 'garageDoor', + 'roof', + 'eave', + ]; + const baseboardBinary = new Uint8Array(pixelCount); + const wallSubLabels = new Uint8Array(pixelCount); + wallSubLabels.fill(WALL_SUB_LABEL_NONE); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols / 2; x++) { + wallSubLabels[y * cols + x] = 0; + } + for (let x = cols / 2; x < cols; x++) { + wallSubLabels[y * cols + x] = 1; + } + } + const nameToId = new Map(mergedRegions.map(r => [r.name, r.id])); + const pickRaw = buildPickMapAfterWallSplit( + labels, + baseboardBinary, + WALL_IDX, + wallSubLabels, + indexToName, + nameToId, + cols, + rows, + ); + const pick = dilatePickBuffer1px(pickRaw, cols, rows); + expect(pickRegionIdAt(pick, cols, 10, 2)).toBe(0); + expect(pickRegionIdAt(pick, cols, 18, 2)).toBe(2); +}); + +test('patchPickMapForManualWallSplit preserves non-wall pick codes', () => { + const cols = 20; + const rows = 10; + const CEILING_IDX = 1; + const WALL_IDX = 3; + const pixelCount = cols * rows; + const labels = new Uint8Array(pixelCount); + labels.fill(CEILING_IDX); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols; x++) { + labels[y * cols + x] = WALL_IDX; + } + } + + const existingPick = new Uint8Array(pixelCount); + existingPick.fill(0); + for (let y = 0; y < 5; y++) { + for (let x = 0; x < cols; x++) { + existingPick[y * cols + x] = 1; // ceiling id 0 + } + } + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols; x++) { + existingPick[y * cols + x] = 2; // old wall id 1 + } + } + + const wallSubLabels = new Uint8Array(pixelCount); + wallSubLabels.fill(WALL_SUB_LABEL_NONE); + for (let y = 5; y < rows; y++) { + for (let x = 0; x < cols / 2; x++) { + wallSubLabels[y * cols + x] = 0; + } + for (let x = cols / 2; x < cols; x++) { + wallSubLabels[y * cols + x] = 1; + } + } + + const baseboardBinary = new Uint8Array(pixelCount); + const nameToId = new Map([ + ['ceiling', 0], + ['wall-1', 3], + ['wall-2', 4], + ]); + + const patched = patchPickMapForManualWallSplit( + existingPick, + labels, + baseboardBinary, + WALL_IDX, + wallSubLabels, + nameToId, + cols, + rows, + ); + + expect(pickRegionIdAt(patched, cols, 10, 2)).toBe(0); + expect(pickRegionIdAt(patched, cols, 3, 7)).toBe(3); + expect(pickRegionIdAt(patched, cols, 15, 7)).toBe(4); +}); + test('single-texture wall becomes wall-1 when splitWalls enabled', () => { const cols = 16; const rows = 8; @@ -196,3 +393,82 @@ test('single-texture wall becomes wall-1 when splitWalls enabled', () => { expect(wallSubs).toHaveLength(1); expect(wallSubs[0].name).toBe('wall-1'); }); + +test('absorbSmallWallGapsForLassoPolygons merges thin unassigned wall slivers', () => { + const cols = 20; + const rows = 1; + const pixelCount = cols * rows; + const labels = new Uint8Array(pixelCount); + labels.fill(WALL_IDX); + const baseboardBinary = new Uint8Array(pixelCount); + const priorAssigned = new Uint8Array(pixelCount); + priorAssigned.fill(WALL_SUB_LABEL_NONE); + + const polyLabels = new Uint8Array(pixelCount); + polyLabels.fill(WALL_SUB_LABEL_NONE); + for (let x = 0; x <= 14; x++) { + polyLabels[x] = 0; + } + + const areas = [15]; + const bboxes = [{ x: 0, y: 0, w: 15, h: 1 }]; + + absorbSmallWallGapsForLassoPolygons( + polyLabels, + 1, + areas, + bboxes, + labels, + baseboardBinary, + WALL_IDX, + priorAssigned, + cols, + rows, + 3, + ); + + expect(polyLabels[15]).toBe(0); + expect(polyLabels[16]).toBe(0); + expect(polyLabels[17]).toBe(0); + expect(areas[0]).toBe(18); +}); + +test('absorbSmallWallGapsForLassoPolygons respects dilation radius limit', () => { + const cols = 20; + const rows = 1; + const pixelCount = cols * rows; + const labels = new Uint8Array(pixelCount); + labels.fill(WALL_IDX); + const baseboardBinary = new Uint8Array(pixelCount); + const priorAssigned = new Uint8Array(pixelCount); + priorAssigned.fill(WALL_SUB_LABEL_NONE); + + const polyLabels = new Uint8Array(pixelCount); + polyLabels.fill(WALL_SUB_LABEL_NONE); + for (let x = 0; x <= 10; x++) { + polyLabels[x] = 0; + } + + const areas = [11]; + const bboxes = [{ x: 0, y: 0, w: 11, h: 1 }]; + + absorbSmallWallGapsForLassoPolygons( + polyLabels, + 1, + areas, + bboxes, + labels, + baseboardBinary, + WALL_IDX, + priorAssigned, + cols, + rows, + 3, + ); + + expect(polyLabels[11]).toBe(0); + expect(polyLabels[12]).toBe(0); + expect(polyLabels[13]).toBe(0); + expect(polyLabels[14]).toBe(WALL_SUB_LABEL_NONE); + expect(areas[0]).toBe(14); +}); diff --git a/dist/components/MaskSegmentCanvas.js b/dist/components/MaskSegmentCanvas.js index ddba574..0cedfb0 100644 --- a/dist/components/MaskSegmentCanvas.js +++ b/dist/components/MaskSegmentCanvas.js @@ -1 +1 @@ -"use strict";var Ct=Object.create;var Rn=Object.defineProperty;var bt=Object.getOwnPropertyDescriptor;var vt=Object.getOwnPropertyNames;var Mt=Object.getPrototypeOf,xt=Object.prototype.hasOwnProperty;var It=(w,M)=>{for(var H in M)Rn(w,H,{get:M[H],enumerable:!0})},Vr=(w,M,H,Ke)=>{if(M&&typeof M=="object"||typeof M=="function")for(let re of vt(M))!xt.call(w,re)&&re!==H&&Rn(w,re,{get:()=>M[re],enumerable:!(Ke=bt(M,re))||Ke.enumerable});return w};var Bt=(w,M,H)=>(H=w!=null?Ct(Mt(w)):{},Vr(M||!w||!w.__esModule?Rn(H,"default",{value:w,enumerable:!0}):H,w)),Ft=w=>Vr(Rn({},"__esModule",{value:!0}),w);var Ot={};It(Ot,{default:()=>Lt});module.exports=Ft(Ot);var f=require("react/jsx-runtime"),e=require("react"),ne=require("react-native"),ee=require("react-native-gesture-handler"),U=require("react-native-reanimated"),Kr=Bt(require("../utils/opencvAdapter")),E=require("../utils/maskSegmentation"),Zr=require("../utils/wallTextureSplit"),Z=require("../utils/pngImage"),Me=require("../utils/resolveImageUrl"),Xr=require("../utils/compositePaintedImage"),Ve=require("../utils/exportUtils"),yn=require("../utils/freqLayerPrep"),$e=require("../utils/paintShaderRuntime"),h=require("../utils/maskSegmentRuntime"),d=require("@shopify/react-native-skia"),c=require("../utils/canvasGeometry");const Et=(0,e.forwardRef)(function(M,H){const{originUrl:Ke,maskUrl:re,originImgPath:Yr,maskImgPath:jr,maskConfig:kn,pipelinePreset:Yn,pipelineConfig:jn,paintConfig:wn,interactionConfig:Sn,semanticColors:Pn,regionOutlineColor:Cn,initialSession:xe,initialPaintColor:Qr,initialPaintConfigJson:et,disabled:Ie=!1,style:nt,onWatch:bn,onPaintCallback:vn,onError:Mn,autoExportOnReady:Qn,onExported:xn}=M,te=Ke??Yr??"",oe=re??jr??"",In=(0,e.useMemo)(()=>Pn?{...kn,semanticColors:Pn}:kn,[kn,Pn]),Ze=(0,e.useMemo)(()=>Cn?{...wn,regionOverlayFill:Cn}:wn,[wn,Cn]),[Be,er]=(0,e.useState)(""),[Fe,nr]=(0,e.useState)(""),[A,rt]=(0,e.useState)(Be),[z,tt]=(0,e.useState)(Fe),rr=(0,e.useRef)(""),tr=(0,e.useRef)(""),Xe=(0,e.useMemo)(()=>(0,h.resolvePipelineConfig)(Yn,jn),[Yn,jn]),Bn=(0,e.useRef)((0,h.createRuntimeConfig)({maskConfig:In,pipelineConfig:Xe,paintConfig:Ze,interactionConfig:Sn})),or=(0,e.useRef)(null),sr=(0,e.useRef)(null);(0,e.useEffect)(()=>{const n=or.current,t=Ze||{},r=!n||["colorBaseOpacity","lLightOpacity","textureOpacity","lLowBlurKernel","lLowContrast","lLowBrightness","lHighGain","maskFeatherColor","maskFeatherTexture","regionOverlayFill"].some(s=>t[s]!==n[s]),i=JSON.stringify(Xe),a=sr.current!==i;(r||a)&&(r&&(or.current={...t}),a&&(sr.current=i),Bn.current=(0,h.setMaskSegmentRuntimeConfig)({maskConfig:In,pipelineConfig:Xe,paintConfig:Ze,interactionConfig:Sn}))},[In,Xe,Ze,Sn]);const ir=Bn.current.paint.palette,ar=(0,h.getMaskSegmentRuntimeConfig)().paint,Wt=(0,h.getMaskSegmentRuntimeConfig)().interaction,cr=(0,e.useRef)(bn),Fn=(0,e.useRef)(vn),En=(0,e.useRef)(Mn),ur=(0,e.useRef)(xn);(0,e.useEffect)(()=>{cr.current=bn,Fn.current=vn,En.current=Mn,ur.current=xn},[bn,vn,Mn,xn]);const Ln=(0,e.useRef)(0),lr=(0,e.useRef)(null),On=(0,e.useRef)(null),P=(0,e.useCallback)((n,t)=>{const o=[n,t?.regionCount??"",t?.maskPathsReady??"",t?.freqLayersReady??"",t?.errorMessage??""].join("|");if(On.current===o)return;On.current=o,lr.current=n;const r=Ln.current?performance.now()-Ln.current:0;cr.current?.(n,r,t)},[]),ae=(0,e.useCallback)((n,t)=>{P("error",{errorMessage:n}),En.current?En.current(n,t):__DEV__&&console.error("[MaskSegment]",n,t)},[P]),[ce,fr]=(0,e.useState)(Qr??null),ue=(0,e.useRef)(et),gr=(0,e.useRef)(te),mr=(0,e.useRef)(oe);(0,e.useEffect)(()=>{gr.current=te,mr.current=oe},[te,oe]),(0,e.useEffect)(()=>{let n=!1;if(!te||!oe){er(""),nr("");return}return(async()=>{try{const[t,o]=await Promise.all([(0,Me.resolveImageUrl)(te,`origin_${(0,Me.hashUrl)(te)}.png`),(0,Me.resolveImageUrl)(oe,`mask_${(0,Me.hashUrl)(oe)}.png`)]);n||(er(t),nr(o))}catch(t){if(!n){const o=t instanceof Error?t.message:String(t);ae(o,t)}}})(),()=>{n=!0}},[te,oe,ae]),(0,e.useEffect)(()=>{rt(Be),tt(Fe),Be&&Fe&&(0,Z.prewarmPngBgrCache)([Be,Fe])},[Be,Fe]),(0,e.useEffect)(()=>{rr.current=A||"",tr.current=z||""},[A,z]);const[Ye,je]=(0,e.useState)(null),C=(0,e.useRef)(null),le=(0,e.useRef)(null),[Qe,Wn]=(0,e.useState)(null),[y,Ee]=(0,e.useState)(()=>new Map),R=(0,e.useRef)(new Map),[dr,Le]=(0,e.useState)([]);R.current=y;const[fe,ge]=(0,e.useState)(null),[An,me]=(0,e.useState)(null),[pr,en]=(0,e.useState)(null),L=(0,e.useRef)(null),Oe=(0,e.useRef)(0),X=(0,e.useRef)(!1),We=(0,e.useRef)([]),nn=(0,e.useRef)(!1);(0,e.useEffect)(()=>{R.current=y},[y]);const Ae=(0,e.useRef)(null),_=(0,e.useRef)(null),rn=(0,e.useRef)(!1),k=(0,e.useRef)([]),zn=(0,e.useRef)(null),ze=(0,e.useRef)(null),De=(0,e.useRef)(null),tn=(0,e.useRef)(null),Y=(0,e.useRef)(null),hr=(0,e.useRef)(()=>Promise.resolve()),[q,Dn]=(0,e.useState)(!1),[on,de]=(0,e.useState)(!1),sn=(0,e.useRef)(null),Tn=(0,e.useRef)(null),[pe,Rr]=(0,e.useState)([]),[yr,kr]=(0,e.useState)(0),[x,ot]=(0,e.useState)(null),wr=(0,d.useCanvasRef)(),[J,Sr]=(0,e.useState)(null),[st,Pr]=(0,e.useState)(!1),[Te,it]=(0,e.useState)(null),[m,Cr]=(0,e.useState)(!1),he=(0,e.useRef)(!1),Re=(0,e.useRef)(!1),[br,vr]=(0,e.useState)(!1),[Mr,xr]=(0,e.useState)(!1),Hn=(0,e.useRef)(!1),[an,Ir]=(0,e.useState)(null),He=(0,e.useRef)(null),j=Ye?.lowFreqImage??null,cn=Ye?.highFreqImage??null,I=Te?.w??1,B=Te?.h??1,at=Te!=null&&Te.w>0&&Te.h>0,N=(0,e.useRef)(I),G=(0,e.useRef)(B),[_e,Br]=(0,e.useState)(1),[qe,_n]=(0,e.useState)({x:0,y:0}),D=(0,e.useRef)(1),V=(0,e.useRef)({x:0,y:0}),Fr=(0,e.useRef)(1),Er=(0,e.useRef)({x:0,y:0}),Lr=(0,e.useRef)({x:0,y:0}),qn=(0,e.useRef)({x:0,y:0}),Un=(0,e.useRef)(null);(0,e.useEffect)(()=>{D.current=_e},[_e]),(0,e.useEffect)(()=>{V.current=qe},[qe]);const ct=(0,e.useCallback)((n,t)=>{n<=0||t<=0||(N.current=n,G.current=t,it(o=>o?.w===n&&o?.h===t?o:{w:n,h:t}))},[]),$=(0,e.useCallback)(()=>{Br(1),_n({x:0,y:0}),D.current=1,V.current={x:0,y:0}},[]),g=(0,e.useMemo)(()=>x?(0,c.getContainRect)(I,B,x.w,x.h):null,[x,I,B]);(0,e.useEffect)(()=>{Un.current=g},[g]);const T=(0,e.useRef)(0),un=(0,e.useRef)(""),ye=(0,e.useRef)(""),ke=(0,e.useRef)(null);(0,e.useEffect)(()=>{C.current=Ye},[Ye]),(0,e.useEffect)(()=>{he.current=m},[m]),(0,e.useEffect)(()=>{Re.current=on},[on]);const Or=(0,e.useCallback)(()=>{!he.current||!C.current||P("layers_ready",{regionCount:k.current.length,maskPathsReady:Re.current,freqLayersReady:!0})},[P]),ln=(0,e.useCallback)(()=>{!he.current||!Re.current||P("mask_paths_ready",{regionCount:k.current.length,maskPathsReady:!0,freqLayersReady:!0})},[P]),Ue=(0,e.useCallback)(()=>{!he.current||!C.current||(Or(),P("interactive",{regionCount:k.current.length,maskPathsReady:Re.current,freqLayersReady:!0}),vr(!0))},[P,Or]),fn=(0,e.useMemo)(()=>{const n=ze.current;if(le.current?.dispose(),!n||y.size===0)return le.current=null,null;const t=(0,$e.createPaintColorMapForPaint)(n.buffer,n.cols,n.rows,y);return le.current=t,t},[y,q,m,(0,h.getMaskRuntimeRevision)()]),se=(0,e.useRef)(new Map),gn=(0,e.useCallback)(async(n,t)=>{const o=++T.current,r=()=>{if(o===T.current)return!1;const u=rr.current||n,l=tr.current||t;return!(u===n&&l===t)},i=(0,h.getMaskSegmentRuntimeConfig)().pipeline;Ln.current=performance.now(),lr.current=null,On.current=null,P("init"),(0,c.timeLog)("\u25B6 start segmentation"),he.current=!1,Cr(!1),Wn(null);const a=new Map;R.current=a,Ee(a),Le([]),ge(null),me(null),en(null),X.current=!1,Oe.current=0,We.current=[],L.current&&(clearTimeout(L.current),L.current=null),nn.current=!1,Ae.current=null,_.current&&(clearTimeout(_.current),_.current=null),rn.current=!1,k.current=[],zn.current=null,ze.current=null,De.current=null,tn.current=null,Y.current=null,Se(new Map),de(!1),Dn(!1),sn.current=null,Tn.current=null,ke.current=null,Rr([]),kr(0),Sr(null),Pr(!1);const s=C.current;s&&((0,c.releasePaintResourceLayers)(s),C.current=null,je(null)),(0,c.releaseOriginSkImage)(He.current),He.current=null,Ir(null);try{(0,c.timeLog)("\u25B6 reading origin PNG");const u=(0,Z.readPngBgrBuffer)(n);(0,c.timeLog)("\u25B6 reading mask PNG");const l=(0,Z.readPngBgrBuffer)(t),p=await u;if(r())return;const b=p.cols,v=p.rows;ot({w:b,h:v});let S=1;Math.max(b,v)>i.maxImageLongSide&&(S=i.maxImageLongSide/Math.max(b,v));const Ce=Math.floor(b*S),be=Math.floor(v*S),Q=i.minContourArea*S*S,yt=(0,c.prepareWorkScaledBgrBuffer)(p.buffer,b,v,S).then(F=>(r()||(tn.current=F,Sr({w:F.cols,h:F.rows}),Pr(!0),hr.current()),F)),kt=l.then(async F=>{if(r())throw new Error("cancelled");(0,c.timeLog)("\u25B6 PNG read completed"),P("images_loaded"),(0,c.timeLog)(`\u25B6 image size: ${b}x${v}`);const{buffer:ve,cols:pn,rows:hn}=F,Ge=(0,Z.resizeBgrBuffer)(ve,pn,hn,Ce,be);return(0,c.timeLog)(`\u25B6 mask scale: ${S.toFixed(3)}`),P("mask_aligned"),(0,E.extractRegionsFromMaskBufferSync)(Ge,Ce,be,{minArea:Q,approxEpsilon:i.contourApproxEpsilon})});l.then(async F=>{const{buffer:ve,cols:pn,rows:hn}=F;let Ge;pn!==b||hn!==v?Ge=await Kr.default.resizeBgrBuffer(ve,pn,hn,b,v):Ge=new Uint8Array(ve),o===T.current&&(zn.current={buffer:Ge,cols:b,rows:v})});const[wt,St]=await Promise.all([kt,yt]);if(r())return;let W=wt;(0,h.getMaskSegmentRuntimeConfig)().mask.splitWalls&&(W=(0,Zr.splitWallRegionsByTexture)(W,St.buffer,Ce,be,Q));const Pt=Y.current??Promise.resolve();P("mask_sampled",{regionCount:W.regions.length}),(0,c.timeLog)(`\u25B6 segmentation completed, valid regions: ${W.regions.length}`);const Nr=W.regions;__DEV__&&Nr.length===0&&console.warn("[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image");let K=Nr;K.length>i.maxRegions&&(K=K.slice(0,i.maxRegions)),k.current=K,ze.current=W.pickMap,De.current={labels:W.labels,baseboardBinary:W.baseboardBinary,cols:W.segCols,rows:W.segRows,wallSubLabels:W.wallSubLabels},sn.current=null,Tn.current=K.find(F=>F.thinStrip)?.id??null;const Gr=(0,c.getContainRect)(N.current,G.current,b,v);ke.current=Gr,Se(new Map),de(!1),Rr(K),kr(K.length),un.current=`${n}|${t}`,he.current=!0,Cr(!0),P("regions_ready",{regionCount:K.length}),Ue(),(async()=>{if(o!==T.current)return;const F=(0,E.downsampleMaskDataForPaths)(De.current,i.maskPathMaxLongSide),ve=(0,E.buildAllRegionOutlinePaths)(K,F,Gr);o===T.current&&(Se(ve),de(!0),Re.current=!0,ln())})(),(async()=>o===T.current&&(await Pt,o===T.current&&(sn.current=(0,E.upscaleBinaryMask)(W.baseboardBinary,Ce,be,b,v))))()}catch(u){const l=u instanceof Error?u.message:String(u);r()||(console.error("[SDK-SEGMENT] segmentation failed",u),ae(l,u))}},[P,Ue,ln,ae]),Jn=(0,e.useCallback)(()=>{if(C.current)return Promise.resolve();if(Y.current)return Y.current;const n=tn.current;if(!n)return Promise.resolve();const t=T.current;let o;return o=(async()=>{(0,c.timeLog)("\u25B6 start loading paint shader textures");try{const r=await(0,yn.preparePaintResourcesFromWorkBuffer)(n.buffer,n.cols,n.rows,i=>{if(t!==T.current){(0,yn.releaseFreqLayerImages)(i);return}je(i),C.current=i,Dn(!0),(0,c.timeLog)("\u25B6 paint shader textures ready"),Ue()});if(t!==T.current){r&&(r.originImage.dispose(),(0,c.releasePaintResourceLayers)(r.layers));return}if(!r)return;(0,c.releaseOriginSkImage)(He.current),He.current=r.originImage,Ir(r.originImage),(0,c.timeLog)("\u25B6 origin Skia work resolution"),C.current||(je(r.layers),C.current=r.layers,Dn(!0)),Ue()}catch(r){__DEV__&&console.warn("[MaskSegment] failed to prepare paint shader textures",r)}finally{Y.current===o&&(Y.current=null)}})(),Y.current=o,o},[Ue]);hr.current=Jn;const Wr=(0,e.useCallback)(async()=>{if(!(Hn.current||!A||!z)){Hn.current=!0;try{$();const n=C.current;n&&((0,c.releasePaintResourceLayers)(n),C.current=null,je(null)),await(0,Z.clearDerivedImageCache)(),un.current="",await gn(A,z)}catch(n){const t=n instanceof Error?n.message:String(n);ae(t,n)}finally{Hn.current=!1}}},[A,z,gn,ae,$]);(0,e.useEffect)(()=>{if(!A||!z)return;const n=`${A}|${z}`;if(!(un.current===n||ye.current===n))return $(),ye.current=n,gn(A,z).finally(()=>{ye.current===n&&(ye.current="")}),()=>{T.current+=1,ye.current===n&&(ye.current="");const t=C.current;t&&((0,c.releasePaintResourceLayers)(t),C.current=null),le.current?.dispose(),le.current=null,k.current=[],We.current=[],X.current=!1,Oe.current=0,L.current&&(clearTimeout(L.current),L.current=null),en(null)}},[A,z,$]);const Nn=(0,e.useCallback)(()=>{const n=[],t=R.current&&R.current.size>0?R.current:y;for(const[o,r]of t){const i=k.current.find(a=>a.id===o);n.push({regionId:o,regionName:i?.name??String(o),color:r,configJson:se.current.get(o)})}return n},[y]),mn=(0,e.useCallback)(n=>{const t=new Map;se.current=new Map;const o=k.current||[],r=new Map;for(const s of o)if(s&&s.name){const u=String(s.name).trim().toLowerCase();u&&!r.has(u)&&r.set(u,s.id)}const i=new Map;for(const s of n.painted||[])s&&typeof s.regionId=="number"&&s.regionName&&i.set(s.regionId,String(s.regionName));for(const s of n.painted||[]){if(!s)continue;let u=s.regionId;if(s.regionName){const l=String(s.regionName).trim().toLowerCase(),p=r.get(l);typeof p=="number"&&(u=p)}t.set(u,s.color),s.configJson&&se.current.set(u,s.configJson)}const a=[];for(const s of n.paintHistory||[]){if(typeof s!="number")continue;const u=i.get(s);if(u){const l=r.get(String(u).trim().toLowerCase());if(typeof l=="number"){a.push(l);continue}}o.some(l=>l&&l.id===s)&&a.push(s)}R.current=t,Ee(t),Le(a.length?a:[...n.paintHistory||[]]),n.currentColor&&(fr(n.currentColor),ue.current=n.currentColorConfigJson,Wn(null))},[]);(0,e.useEffect)(()=>{!xe||!m||nn.current||(nn.current=!0,mn(xe))},[xe,m,mn]),(0,e.useEffect)(()=>()=>{L.current&&clearTimeout(L.current)},[]);const ie=(0,e.useCallback)(()=>{X.current=!1,L.current&&(clearTimeout(L.current),L.current=null),en(null)},[]),Ar=(0,e.useCallback)(()=>{const n=(0,h.getMaskSegmentRuntimeConfig)().interaction;if(!n.enableInitRegionFlash||X.current)return;const t=k.current;if(t.length===0)return;const o=we.current,r=o?Math.max(500,o.w*o.h*.002):500;We.current=t.filter(a=>!R.current.has(a.id)&&a.area>=r),X.current=!0,Oe.current=0;const i=()=>{if(!X.current||We.current.length===0)return;const a=We.current,s=Oe.current;if(s>=a.length){ie();return}en(a[s].id),Oe.current+=1,L.current=setTimeout(i,n.initRegionFlashMs)};i()},[]),O=(0,e.useCallback)(()=>{ie()},[ie]);(0,e.useEffect)(()=>{X.current&&R.current.size>0&&ie()},[y,ie]),(0,e.useEffect)(()=>{if(m&&on&&g&&yr>0&&br){X.current||Ar();return}m||(ie(),vr(!1))},[m,on,g,yr,br,Ar,ie]);const zr=(0,e.useCallback)(()=>ce||(Qe==null?null:ir[Qe]??null),[ce,Qe,ir]),Je=ce!=null||Qe!=null,Dr=(0,e.useCallback)((n,t)=>{let o=!1;if(Ee(r=>{const i=r.get(n);if(i&&(0,c.bgrColorEquals)(i,t))return r;o=!0,Le(s=>s[s.length-1]===n?s:[...s.filter(l=>l!==n),n]);const a=new Map(r);return a.set(n,t),R.current=a,a}),o){const r=ue.current??se.current.get(n);ue.current&&se.current.set(n,ue.current);const i=k.current.find(a=>a.id===n);Fn.current?.({kind:"painted",regionId:n,regionName:i?.name??String(n),color:t,configJson:r})}},[]),Ne=(0,e.useCallback)((n,t,o=!1)=>{if(!m||!x||k.current.length===0)return null;const r=(0,c.canvasToNormalized)(n,t,I,B,x.w,x.h);if(!r)return null;const i=ze.current;if(i){const u=(0,c.lookupRegionFromPickMap)(r.x,r.y,i,o?0:(0,h.getMaskSegmentRuntimeConfig)().interaction.pickMapSearchRadiusPx);if(u!=null)return u;if(o)return null}const a=zn.current,s=Tn.current;if(!o){const u=(0,c.resolveRegionHit)(k.current,r.x,r.y);if(u!=null)return u}if(a&&s!=null){const u=sn.current,l=(0,c.pickKickRegionFromMask)(r.x,r.y,a,s,u,o);if(l!=null)return l;if(!o){const p=k.current.find(Pe=>Pe.id===s);if(p&&(0,c.pickKickNearStrip)(r.x,r.y,p))return s}}return null},[m,x,I,B]),Gn=(0,e.useCallback)((n,t)=>{if(O(),!m||!x||k.current.length===0||!Je||!q||Ie)return;const o=Ne(n,t);if(o==null)return;const r=zr();r&&(Dr(o,r),!q&&!Y.current&&Jn())},[m,x,Je,q,Ie,Ne,zr,Dr,O,Jn]),Vn=(0,e.useRef)(Je),Tr=(0,e.useRef)(Ie),Hr=(0,e.useRef)(m),we=(0,e.useRef)(x);(0,e.useEffect)(()=>{N.current=I},[I]),(0,e.useEffect)(()=>{G.current=B},[B]),(0,e.useEffect)(()=>{Vn.current=Je},[Je]),(0,e.useEffect)(()=>{Tr.current=Ie},[Ie]),(0,e.useEffect)(()=>{Hr.current=m},[m]),(0,e.useEffect)(()=>{we.current=x},[x]);const $n=(0,e.useRef)(Ne),Kn=(0,e.useRef)(Gn),_r=(0,e.useRef)(O),Zn=(0,e.useRef)($);(0,e.useEffect)(()=>{$n.current=Ne},[Ne]),(0,e.useEffect)(()=>{Kn.current=Gn},[Gn]),(0,e.useEffect)(()=>{_r.current=O},[O]),(0,e.useEffect)(()=>{Zn.current=$},[$]);const Xn=(0,e.useCallback)(()=>{O(),Le(n=>{if(n.length===0)return n;const t=n[n.length-1];return Ee(o=>{const r=new Map(o);return r.delete(t),R.current=r,r}),se.current.delete(t),n.slice(0,-1)})},[O]),qr=(0,e.useCallback)(()=>{O();const n=new Map;R.current=n,Ee(n),Le([]),se.current=new Map,Ae.current=null,$()},[O,$]),Ur=(0,e.useCallback)(async()=>{try{const n=wr.current;if(!n||!J)return;let o=n.makeImageSnapshot?.();if(o||(await new Promise(a=>requestAnimationFrame(()=>a())),o=n.makeImageSnapshot?.()),!o)return;const r=o.encodeToBase64;if(typeof r!="function")return;const i=r.call(o)||"";return i.length>0?i:void 0}catch(n){console.warn("[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:",n);return}},[J]),dn=(0,e.useCallback)(async(n,t)=>{const o=tn.current,r=ze.current;if(!o||!r)throw new Error("image not ready, cannot save");let i;n.size>0&&(i=await Ur());const a=C.current,s=le.current,u=He.current??a?.lowFreqImage??null,l=!i&&u&&a&&s&&q?{originImage:u,paintColorMap:s,lowFreqImage:a.lowFreqImage,highFreqImage:a.highFreqImage}:void 0,p=await(0,Xr.compositePaintedImage)({originBuffer:o.buffer,cols:o.cols,rows:o.rows,pickBuffer:r.buffer,paintedRegions:n,destDir:t,...i?{exportPngBase64:i}:{},shaderTextures:l,renderWidth:o.cols,renderHeight:o.rows});return Ae.current={fingerprint:(0,Ve.paintedRegionsFingerprint)(n),result:p},p},[Ur,q]);(0,e.useEffect)(()=>{if(Qn&&!(!m||!q)&&!(xe&&!nn.current)&&y.size!==0)return _.current&&clearTimeout(_.current),_.current=setTimeout(()=>{if(_.current=null,rn.current)return;const n=R.current;!n||n.size===0||(rn.current=!0,(async()=>{try{const t=await dn(n);ur.current?.(t)}catch(t){console.log("[VIZ-SAVE] debounced autoExport threw (non-fatal):",t)}finally{rn.current=!1}})())},400),()=>{_.current&&(clearTimeout(_.current),_.current=null)}},[Qn,m,q,xe,y,dn]),(0,e.useImperativeHandle)(H,()=>({reset:Xn,swap:n=>{O(),xr(n===void 0?t=>!t:n)},save:async n=>{const t=R.current&&R.current.size>0?R.current:y,o=(0,Ve.paintedRegionsFingerprint)(t),r=Ae.current;if(r?.fingerprint===o&&r.result)return(0,Ve.resolveExportResultForDestDir)(r.result,n?.destDir);try{return await dn(t,n?.destDir)}catch(i){throw console.error("[VIZ-SAVE] SDK save() composite threw:",i),i}},getLastExport:()=>Ae.current?.result??null,session:()=>({version:1,originUrl:gr.current,maskUrl:mr.current,painted:Nn(),paintHistory:[...dr],currentColor:ce??void 0,currentColorConfigJson:ue.current,savedAt:Date.now()}),loadSession:mn,setPaintColor:(n,t)=>{fr(n),ue.current=t,Wn(null)},setMaskConfig:n=>{(0,h.setMaskSegmentRuntimeConfig)({maskConfig:n}),Bn.current=(0,h.getMaskSegmentRuntimeConfig)(),A&&z&&(un.current="",gn(A,z))},clearAllPaint:qr,undoSelection:Xn,resegment:Wr,getRegions:()=>[...k.current],getPaintedRegions:()=>Nn()}),[Xn,O,y,dr,ce,Nn,mn,qr,Wr,dn]);const[ut,Se]=(0,e.useState)(new Map);(0,e.useEffect)(()=>{if(!m||!g||pe.length===0){m||(Se(new Map),de(!1),ke.current=null);return}const n=De.current;if(!n){Se(new Map),de(!1);return}if(ke.current&&(0,c.rectsEqual)(ke.current,g))return;const t=__DEV__?performance.now():0,o=(0,E.downsampleMaskDataForPaths)(n,(0,h.getMaskSegmentRuntimeConfig)().pipeline.maskPathMaxLongSide),r=(0,E.buildAllRegionOutlinePaths)(pe,o,g);ke.current=g,Se(r),de(!0),Re.current=!0,ln()},[m,g,pe,ln]);const At=(0,e.useMemo)(()=>{if(fe==null||An==null||!g||pe.length===0)return null;const n=De.current;if(!n)return null;const t=(0,E.downsampleMaskDataForPaths)(n,(0,h.getMaskSegmentRuntimeConfig)().pipeline.maskPathMaxLongSide);return(0,E.buildRegionOutlinePathForRegion)(fe,pe,t,g,An)},[fe,An,g,pe]),lt=(n,t=1)=>!n||!g?null:(0,f.jsx)(d.Image,{image:n,x:g.x,y:g.y,width:g.w,height:g.h,opacity:t}),Jr=(n,t)=>{const o=ut.get(n);return!o||!g?null:(0,f.jsxs)(f.Fragment,{children:[(0,f.jsx)(d.Path,{path:o,color:ar.regionOverlayFill,style:"fill",opacity:.05},`${t}-fill-${n}`),(0,f.jsx)(d.Path,{path:o,color:ar.regionOverlayFill,style:"stroke",strokeWidth:3,strokeJoin:"round",antiAlias:!0,children:(0,f.jsx)(d.DashPathEffect,{intervals:[8,6]})},`${t}-stroke-${n}`)]})},ft=(0,e.useMemo)(()=>{if(!(_e<=1))return(0,c.buildZoomPanMatrix)(qe.x,qe.y,_e,I,B)},[_e,qe,I,B]),gt=()=>{const n=an??j;if(!n||!g)return null;const t=!Mr&&m,o=fn&&j&&cn&&q,r=!Mr&&y.size>0&&o,i=an??j;return(0,f.jsxs)(f.Fragment,{children:[(0,f.jsx)(d.Rect,{x:0,y:0,width:I,height:B,color:"#F0F1F3"}),(0,f.jsx)(d.Group,{clip:d.Skia.XYWHRect(0,0,I,B),children:(0,f.jsxs)(d.Group,{matrix:ft,children:[r&&i?(0,f.jsx)($e.PaintShaderLayer,{originImage:i,paintColorMap:fn,lowFreqImage:j,highFreqImage:cn,x:g.x,y:g.y,width:g.w,height:g.h,showOrigin:!1}):lt(n),t&&pr!=null&&Jr(pr,"init-overlay"),t&&fe!=null&&!y.has(fe)&&Jr(fe,"hold-overlay")]})})]})},mt=()=>{const n=J;if(!n)return null;const t=n.w,o=n.h,r=fn&&j&&cn&&q,i=y.size>0&&r,a=an??j;if(i&&a)return(0,f.jsx)($e.PaintShaderLayer,{originImage:a,paintColorMap:fn,lowFreqImage:j,highFreqImage:cn,x:0,y:0,width:t,height:o,showOrigin:!1});const s=an??j;return s?(0,f.jsx)(d.Image,{image:s,x:0,y:0,width:t,height:o}):null},dt=(0,e.useMemo)(()=>{const n=(r,i)=>{_r.current?.();const a=(0,c.screenToCanvasCoords)(r,i,N.current,G.current,D.current,V.current),s=$n.current(a.x,a.y,!0);if(s==null||!we.current){ge(null),me(null);return}if(R.current&&R.current.has(s)){ge(null),me(null);return}const u=(0,c.canvasToNormalized)(a.x,a.y,N.current,G.current,we.current.w,we.current.h);ge(s),me(u)},t=(r,i,a)=>{if(!a)return;const s=(0,c.screenToCanvasCoords)(r,i,N.current,G.current,D.current,V.current);if(Vn.current)Kn.current(s.x,s.y);else{if(O(),Tr.current||!Hr.current||!we.current)return;const u=$n.current(s.x,s.y);if(u==null)return;const l=k.current.find(p=>p.id===u);Fn.current?.({kind:"brush_required",hint:"please select a brush color first",regionId:u,regionName:l?.name??String(u)})}},o=(r,i)=>{if(Vn.current){ge(null),me(null);return}const a=(0,c.screenToCanvasCoords)(r,i,N.current,G.current,D.current,V.current);Kn.current(a.x,a.y),ge(null),me(null)};return ee.Gesture.Tap().onBegin(r=>{"worklet";(0,U.runOnJS)(n)(r.x,r.y)}).onEnd((r,i)=>{"worklet";(0,U.runOnJS)(t)(r.x,r.y,i)}).onFinalize(r=>{"worklet";(0,U.runOnJS)(o)(r.x,r.y)})},[]),pt=(0,e.useMemo)(()=>{const n=(r,i)=>{Fr.current=D.current,Er.current={...V.current},Lr.current={x:r,y:i}},t=(r,i,a)=>{const s=N.current,u=G.current;if(s<=0||u<=0)return;const l=s/2,p=u/2,Pe=Fr.current,b=Er.current,v=Lr.current;let S=Math.max(1,Math.min(Pe*r,5));const Ce=(v.x-b.x-l)/Pe+l,be=(v.y-b.y-p)/Pe+p;let Q={x:i-l-S*(Ce-l),y:a-p-S*(be-p)};S<=1?(S=1,Q={x:0,y:0}):Q=(0,c.clampPanOffset)(Q,S,s,u,Un.current),Br(S),_n(Q),D.current=S,V.current=Q},o=()=>{D.current<=1.01&&Zn.current?.()};return ee.Gesture.Pinch().onStart(r=>{"worklet";(0,U.runOnJS)(n)(r.focalX,r.focalY)}).onUpdate(r=>{"worklet";(0,U.runOnJS)(t)(r.scale,r.focalX,r.focalY)}).onEnd(()=>{"worklet";(0,U.runOnJS)(o)()})},[]),ht=(0,e.useMemo)(()=>{const n=()=>{qn.current={...V.current}},t=(r,i)=>{if(D.current<=1)return;const a=(0,c.clampPanOffset)({x:qn.current.x+r,y:qn.current.y+i},D.current,N.current,G.current,Un.current);_n(a),V.current=a},o=()=>{D.current<=1.01&&Zn.current?.()};return ee.Gesture.Pan().minPointers(1).maxPointers(1).minDistance(10).onStart(()=>{"worklet";(0,U.runOnJS)(n)()}).onUpdate(r=>{"worklet";(0,U.runOnJS)(t)(r.translationX,r.translationY)}).onEnd(()=>{"worklet";(0,U.runOnJS)(o)()})},[]),Rt=(0,e.useMemo)(()=>ee.Gesture.Exclusive(ee.Gesture.Simultaneous(pt,ht),dt),[]);return(0,f.jsxs)(ne.View,{style:[$r.container,nt],children:[(0,f.jsx)(ne.View,{style:$r.canvasWrap,onLayout:n=>{const{width:t,height:o}=n.nativeEvent.layout;ct(t,o)},children:at?(0,f.jsx)(ee.GestureDetector,{gesture:Rt,children:(0,f.jsx)(ne.View,{style:{width:I,height:B},children:(0,f.jsx)(d.Canvas,{style:{width:I,height:B},pointerEvents:"none",children:gt()})})}):null}),st&&J?(0,f.jsx)(ne.View,{style:{position:"absolute",left:-J.w-200,top:0,width:J.w,height:J.h,opacity:0,overflow:"hidden"},pointerEvents:"none",children:(0,f.jsx)(d.Canvas,{ref:wr,style:{width:J.w,height:J.h},pointerEvents:"none",children:(0,f.jsx)(d.Group,{children:mt()})})}):null]})}),$r=ne.StyleSheet.create({container:{flex:1,backgroundColor:"#fff"},canvasWrap:{flex:1,width:"100%",alignSelf:"stretch",position:"relative",backgroundColor:"#f5f5f5",borderWidth:ne.StyleSheet.hairlineWidth,borderColor:"#ddd",overflow:"hidden"}});var Lt=Et; +"use strict";var zo=Object.create;var Cr=Object.defineProperty;var $o=Object.getOwnPropertyDescriptor;var Uo=Object.getOwnPropertyNames;var Go=Object.getPrototypeOf,Jo=Object.prototype.hasOwnProperty;var qo=(x,v)=>{for(var E in v)Cr(x,E,{get:v[E],enumerable:!0})},ao=(x,v,E,H)=>{if(v&&typeof v=="object"||typeof v=="function")for(let N of Uo(v))!Jo.call(x,N)&&N!==E&&Cr(x,N,{get:()=>v[N],enumerable:!(H=$o(v,N))||H.enumerable});return x};var Xo=(x,v,E)=>(E=x!=null?zo(Go(x)):{},ao(v||!x||!x.__esModule?Cr(E,"default",{value:x,enumerable:!0}):E,x)),Ko=x=>ao(Cr({},"__esModule",{value:!0}),x);var ns={};qo(ns,{default:()=>es});module.exports=Ko(ns);var I=require("react/jsx-runtime"),e=require("react"),Je=require("react-native"),ve=require("react-native-gesture-handler"),fe=require("react-native-reanimated"),uo=Xo(require("../utils/opencvAdapter")),de=require("../utils/maskSegmentation"),J=require("../utils/wallTextureSplit"),$=require("../utils/magneticLasso"),fo=require("../utils/activeContour"),_e=require("../utils/pngImage"),In=require("../utils/resolveImageUrl"),mo=require("../utils/compositePaintedImage"),Zn=require("../utils/exportUtils"),Lr=require("../utils/freqLayerPrep"),Ln=require("../utils/paintShaderRuntime"),F=require("../utils/maskSegmentRuntime"),S=require("@shopify/react-native-skia"),f=require("../utils/canvasGeometry");function at(x,v,E,H,N,T,X,K){const Q=(0,f.getContainRect)(N,T,X,K),cn=Q.x+E*Q.w,Pe=Q.y+H*Q.h;return Math.hypot(x-cn,v-Pe)}function Zo(x,v,E,H,N,T,X,K){if(!X||X.length<3)return!1;const Q=X[0];return at(x,v,Q.x,Q.y,E,H,N,T){const Wn=Pe.x+re*Pe.w,En=Pe.y+qe*Pe.h,Ke=Math.hypot(x-Wn,v-En);Ke<=X&&KeH.name==="wall");return E<0?null:{labels:x.labels,baseboardBinary:x.baseboardBinary,cols:x.cols,rows:x.rows,wallSemanticIdx:E}}function vr(x,v,E){if(!E?.wallSubLabels||E.cols<=0||E.rows<=0)return!1;const{wallSubLabels:H,cols:N,rows:T}=E,X=Math.min(N-1,Math.max(0,Math.floor(x*N))),K=Math.min(T-1,Math.max(0,Math.floor(v*T)));return H[K*N+X]!==J.WALL_SUB_LABEL_NONE}function Ir(x,v,E,H,N){for(const T of E)if(T.vertices.length>=3&&(0,f.pointInPolygon)(x,v,T.vertices))return!0;for(const[T,X]of H)if(!(T===N||!X.isClosed||X.vertices.length<3)&&(0,f.pointInPolygon)(x,v,X.vertices))return!0;return!1}function co(x,v,E,H,N,T){return!H||!(0,$.isNormPointOnWallMask)(x,v,H)||vr(x,v,E)?!1:!Ir(x,v,N,T)}function jo(x,v,E,H,N){for(const T of x)if(vr(T.x,T.y,v)||Ir(T.x,T.y,E,H,N))return!0;if(x.length>=3){const T=x.reduce((K,Q)=>K+Q.x,0)/x.length,X=x.reduce((K,Q)=>K+Q.y,0)/x.length;if(vr(T,X,v)||Ir(T,X,E,H,N))return!0}return!1}const Qo=(0,e.forwardRef)(function(v,E){const{originUrl:H,maskUrl:N,originImgPath:T,maskImgPath:X,maskConfig:K,pipelinePreset:Q,pipelineConfig:cn,paintConfig:Pe,interactionConfig:un,semanticColors:fn,regionOutlineColor:mn,initialSession:re,initialPaintColor:qe,initialPaintConfigJson:Ie,disabled:Xe=!1,style:Wr,onWatch:Wn,onPaintCallback:En,onError:Ke,autoExportOnReady:lt,onExported:Er}=v,Ze=H??T??"",Ye=N??X??"",Ar=(0,e.useMemo)(()=>fn?{...K,semanticColors:fn}:K,[K,fn]),Yn=(0,e.useMemo)(()=>mn?{...Pe,regionOverlayFill:mn}:Pe,[Pe,mn]),[An,ct]=(0,e.useState)(""),[Bn,it]=(0,e.useState)(""),[xe,go]=(0,e.useState)(An),[Re,po]=(0,e.useState)(Bn),ut=(0,e.useRef)(""),ft=(0,e.useRef)(""),jn=(0,e.useMemo)(()=>(0,F.resolvePipelineConfig)(Q,cn),[Q,cn]),Br=(0,e.useRef)((0,F.createRuntimeConfig)({maskConfig:Ar,pipelineConfig:jn,paintConfig:Yn,interactionConfig:un})),mt=(0,e.useRef)(null),gt=(0,e.useRef)(null);(0,e.useEffect)(()=>{const n=mt.current,t=Yn||{},r=!n||["colorBaseOpacity","lLightOpacity","textureOpacity","lLowBlurKernel","lLowContrast","lLowBrightness","lHighGain","maskFeatherColor","maskFeatherTexture","regionOverlayFill"].some(l=>t[l]!==n[l]),i=JSON.stringify(jn),a=gt.current!==i;(r||a)&&(r&&(mt.current={...t}),a&&(gt.current=i),Br.current=(0,F.setMaskSegmentRuntimeConfig)({maskConfig:Ar,pipelineConfig:jn,paintConfig:Yn,interactionConfig:un}))},[Ar,jn,Yn,un]);const dt=Br.current.paint.palette,pt=(0,F.getMaskSegmentRuntimeConfig)().paint,rs=(0,F.getMaskSegmentRuntimeConfig)().interaction,yt=(0,e.useRef)(Wn),Fr=(0,e.useRef)(En),Or=(0,e.useRef)(Ke),ht=(0,e.useRef)(Er);(0,e.useEffect)(()=>{yt.current=Wn,Fr.current=En,Or.current=Ke,ht.current=Er},[Wn,En,Ke,Er]);const Tr=(0,e.useRef)(0),bt=(0,e.useRef)(null),_r=(0,e.useRef)(null),ae=(0,e.useCallback)((n,t)=>{const o=[n,t?.regionCount??"",t?.maskPathsReady??"",t?.freqLayersReady??"",t?.errorMessage??""].join("|");if(_r.current===o)return;_r.current=o,bt.current=n;const r=Tr.current?performance.now()-Tr.current:0;yt.current?.(n,r,t)},[]),gn=(0,e.useCallback)((n,t)=>{ae("error",{errorMessage:n}),Or.current?Or.current(n,t):__DEV__&&console.error("[MaskSegment]",n,t)},[ae]),[dn,wt]=(0,e.useState)(qe??null),pn=(0,e.useRef)(Ie),xt=(0,e.useRef)(Ze),Rt=(0,e.useRef)(Ye);(0,e.useEffect)(()=>{xt.current=Ze,Rt.current=Ye},[Ze,Ye]),(0,e.useEffect)(()=>{let n=!1;if(!Ze||!Ye){ct(""),it("");return}return(async()=>{try{const[t,o]=await Promise.all([(0,In.resolveImageUrl)(Ze,`origin_${(0,In.hashUrl)(Ze)}.png`),(0,In.resolveImageUrl)(Ye,`mask_${(0,In.hashUrl)(Ye)}.png`)]);n||(ct(t),it(o))}catch(t){if(!n){const o=t instanceof Error?t.message:String(t);gn(o,t)}}})(),()=>{n=!0}},[Ze,Ye,gn]),(0,e.useEffect)(()=>{go(An),po(Bn),An&&Bn&&(0,_e.prewarmPngBgrCache)([An,Bn])},[An,Bn]),(0,e.useEffect)(()=>{ut.current=xe||"",ft.current=Re||""},[xe,Re]);const[Qn,er]=(0,e.useState)(null),le=(0,e.useRef)(null),Ne=(0,e.useRef)(null),[nr,Nr]=(0,e.useState)(null),[ee,je]=(0,e.useState)(()=>new Map),V=(0,e.useRef)(new Map),[rr,Qe]=(0,e.useState)([]),tr=(0,e.useRef)([]);V.current=ee;const[yn,hn]=(0,e.useState)(null),[Dr,bn]=(0,e.useState)(null),[kt,or]=(0,e.useState)(null),pe=(0,e.useRef)(null),Fn=(0,e.useRef)(0),De=(0,e.useRef)(!1),On=(0,e.useRef)([]),sr=(0,e.useRef)(!1);(0,e.useEffect)(()=>{V.current=ee},[ee]),(0,e.useEffect)(()=>{tr.current=rr},[rr]);const Tn=(0,e.useRef)(null),Me=(0,e.useRef)(null),ar=(0,e.useRef)(!1),U=(0,e.useRef)([]),Hr=(0,e.useRef)(null),Le=(0,e.useRef)(null),te=(0,e.useRef)(null),_n=(0,e.useRef)(null),He=(0,e.useRef)(null),St=(0,e.useRef)(()=>Promise.resolve()),[Ce,Vr]=(0,e.useState)(!1),[lr,wn]=(0,e.useState)(!1),cr=(0,e.useRef)(null),ir=(0,e.useRef)(null),[en,ur]=(0,e.useState)([]),[Pt,fr]=(0,e.useState)(0),[ne,yo]=(0,e.useState)(null),Mt=(0,S.useCanvasRef)(),[We,Ct]=(0,e.useState)(null),[ho,vt]=(0,e.useState)(!1),[Nn,bo]=(0,e.useState)(null),[G,It]=(0,e.useState)(!1),xn=(0,e.useRef)(!1),Rn=(0,e.useRef)(!1),[Lt,Wt]=(0,e.useState)(!1),[Et,At]=(0,e.useState)(!1),zr=(0,e.useRef)(!1),[mr,Bt]=(0,e.useState)(null),Dn=(0,e.useRef)(null),Ve=Qn?.lowFreqImage??null,gr=Qn?.highFreqImage??null,me=Nn?.w??1,ge=Nn?.h??1,wo=Nn!=null&&Nn.w>0&&Nn.h>0,ye=(0,e.useRef)(me),he=(0,e.useRef)(ge),[Hn,Ft]=(0,e.useState)(1),[Vn,$r]=(0,e.useState)({x:0,y:0}),ce=(0,e.useRef)(1),be=(0,e.useRef)({x:0,y:0}),Ot=(0,e.useRef)(1),Tt=(0,e.useRef)({x:0,y:0}),_t=(0,e.useRef)({x:0,y:0}),Ur=(0,e.useRef)({x:0,y:0}),Gr=(0,e.useRef)(null),[kn,dr]=(0,e.useState)(!1),Sn=(0,e.useRef)(!1),[Jr,ze]=(0,e.useState)(new Map),Y=(0,e.useRef)(new Map),[zn,Ee]=(0,e.useState)(null),Z=(0,e.useRef)(null),[Nt,qr]=(0,e.useState)([]),j=(0,e.useRef)([]),Dt=(0,e.useRef)(0),xo="#FF6B35",Ro="#00C853",ko=32,Ht=20,So=10,Po=14,Mo=12,Co=20,vo=8,nn=(0,e.useRef)(null),$e=(0,e.useRef)(null),pr=(0,e.useRef)(!1),rn=(0,e.useRef)(null),[$n,Xr]=(0,e.useState)(null),[Vt,yr]=(0,e.useState)(null),tn=(0,e.useRef)(null);(0,e.useEffect)(()=>{ce.current=Hn},[Hn]),(0,e.useEffect)(()=>{be.current=Vn},[Vn]),(0,e.useEffect)(()=>{Sn.current=kn},[kn]),(0,e.useEffect)(()=>{Y.current=Jr},[Jr]),(0,e.useEffect)(()=>{Z.current=zn},[zn]),(0,e.useEffect)(()=>{j.current=Nt},[Nt]),(0,e.useEffect)(()=>{tn.current=Vt},[Vt]);const Io=(0,e.useCallback)((n,t)=>{n<=0||t<=0||(ye.current=n,he.current=t,bo(o=>o?.w===n&&o?.h===t?o:{w:n,h:t}))},[]),Ae=(0,e.useCallback)(()=>{Ft(1),$r({x:0,y:0}),ce.current=1,be.current={x:0,y:0}},[]),_=(0,e.useMemo)(()=>ne?(0,f.getContainRect)(me,ge,ne.w,ne.h):null,[ne,me,ge]);(0,e.useEffect)(()=>{Gr.current=_},[_]);const ke=(0,e.useRef)(0),hr=(0,e.useRef)(""),Pn=(0,e.useRef)(""),Ue=(0,e.useRef)(null),br=(0,e.useRef)(""),[Lo,zt]=(0,e.useState)(0);(0,e.useEffect)(()=>{le.current=Qn},[Qn]),(0,e.useEffect)(()=>{xn.current=G},[G]),(0,e.useEffect)(()=>{Rn.current=lr},[lr]);const $t=(0,e.useCallback)(()=>{!xn.current||!le.current||ae("layers_ready",{regionCount:U.current.length,maskPathsReady:Rn.current,freqLayersReady:!0})},[ae]),wr=(0,e.useCallback)(()=>{!xn.current||!Rn.current||ae("mask_paths_ready",{regionCount:U.current.length,maskPathsReady:!0,freqLayersReady:!0})},[ae]),Un=(0,e.useCallback)(()=>{!xn.current||!le.current||($t(),ae("interactive",{regionCount:U.current.length,maskPathsReady:Rn.current,freqLayersReady:!0}),Wt(!0))},[ae,$t]),xr=(0,e.useMemo)(()=>{const n=Le.current;if(Ne.current?.dispose(),!n||ee.size===0)return Ne.current=null,null;const t=(0,Ln.createPaintColorMapForPaint)(n.buffer,n.cols,n.rows,ee);return Ne.current=t,t},[ee,Ce,G,Lo,(0,F.getMaskRuntimeRevision)()]),Be=(0,e.useRef)(new Map),Rr=(0,e.useCallback)(async(n,t)=>{const o=++ke.current,r=()=>{if(o===ke.current)return!1;const c=ut.current||n,m=ft.current||t;return!(c===n&&m===t)},i=(0,F.getMaskSegmentRuntimeConfig)().pipeline;Tr.current=performance.now(),bt.current=null,_r.current=null,ae("init"),(0,f.timeLog)("\u25B6 start segmentation"),xn.current=!1,It(!1),Nr(null);const a=new Map;V.current=a,je(a),Qe([]),hn(null),bn(null),or(null),De.current=!1,Fn.current=0,On.current=[],pe.current&&(clearTimeout(pe.current),pe.current=null),sr.current=!1,dr(!1),Sn.current=!1,ze(new Map),Y.current=new Map,Ee(null),Z.current=null,qr([]),j.current=[],yr(null),tn.current=null,Tn.current=null,Me.current&&(clearTimeout(Me.current),Me.current=null),ar.current=!1,U.current=[],Hr.current=null,Le.current=null,te.current=null,_n.current=null,He.current=null,Mn(new Map),wn(!1),Vr(!1),cr.current=null,ir.current=null,Ue.current=null,ur([]),fr(0),Ct(null),vt(!1);const l=le.current;l&&((0,f.releasePaintResourceLayers)(l),le.current=null,er(null)),(0,f.releaseOriginSkImage)(Dn.current),Dn.current=null,Bt(null);try{(0,f.timeLog)("\u25B6 reading origin PNG");const c=(0,_e.readPngBgrBuffer)(n);(0,f.timeLog)("\u25B6 reading mask PNG");const m=(0,_e.readPngBgrBuffer)(t),u=await c;if(r())return;const g=u.cols,d=u.rows;yo({w:g,h:d});let R=1;Math.max(g,d)>i.maxImageLongSide&&(R=i.maxImageLongSide/Math.max(g,d));const y=Math.floor(g*R),k=Math.floor(d*R),h=i.minContourArea*R*R,O=(0,f.prepareWorkScaledBgrBuffer)(u.buffer,g,d,R).then(z=>(r()||(_n.current=z,Ct({w:z.cols,h:z.rows}),vt(!0),St.current()),z)),P=m.then(async z=>{if(r())throw new Error("cancelled");(0,f.timeLog)("\u25B6 PNG read completed"),ae("images_loaded"),(0,f.timeLog)(`\u25B6 image size: ${g}x${d}`);const{buffer:Se,cols:an,rows:Cn}=z,ln=(0,_e.resizeBgrBuffer)(Se,an,Cn,y,k);return(0,f.timeLog)(`\u25B6 mask scale: ${R.toFixed(3)}`),ae("mask_aligned"),(0,de.extractRegionsFromMaskBufferSync)(ln,y,k,{minArea:h,approxEpsilon:i.contourApproxEpsilon})});m.then(async z=>{const{buffer:Se,cols:an,rows:Cn}=z;let ln;an!==g||Cn!==d?ln=await uo.default.resizeBgrBuffer(Se,an,Cn,g,d):ln=new Uint8Array(Se),o===ke.current&&(Hr.current={buffer:ln,cols:g,rows:d})});const[w,L]=await Promise.all([P,O]);if(r())return;let M=w;const b=(0,F.getMaskSegmentRuntimeConfig)().mask;b.splitWalls&&!b.manualSplitWalls&&(M=(0,J.splitWallRegionsByTexture)(M,L.buffer,y,k,h));const A=He.current??Promise.resolve();ae("mask_sampled",{regionCount:M.regions.length}),(0,f.timeLog)(`\u25B6 segmentation completed, valid regions: ${M.regions.length}`);const oe=M.regions;__DEV__&&oe.length===0&&console.warn("[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image");let C=oe;C.length>i.maxRegions&&(C=C.slice(0,i.maxRegions)),U.current=C,Le.current=M.pickMap;const ie=(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors.map(z=>z.name),sn=ie.indexOf("wall");te.current={labels:M.labels,baseboardBinary:M.baseboardBinary,cols:M.segCols,rows:M.segRows,wallSubLabels:M.wallSubLabels,indexToName:ie,wallSemanticIdx:sn>=0?sn:void 0},cr.current=null,ir.current=C.find(z=>z.thinStrip)?.id??null;const Ge=(0,f.getContainRect)(ye.current,he.current,g,d);Ue.current=Ge,Mn(new Map),wn(!1),ur(C),fr(C.length),hr.current=`${n}|${t}`,xn.current=!0,It(!0),ae("regions_ready",{regionCount:C.length}),Un(),(async()=>{if(o!==ke.current)return;const z=(0,de.downsampleMaskDataForPaths)(te.current,i.maskPathMaxLongSide),Se=(0,de.buildAllRegionOutlinePaths)(C,z,Ge);o===ke.current&&(Mn(Se),wn(!0),Rn.current=!0,wr())})(),(async()=>o===ke.current&&(await A,o===ke.current&&(cr.current=(0,de.upscaleBinaryMask)(M.baseboardBinary,y,k,g,d))))()}catch(c){const m=c instanceof Error?c.message:String(c);r()||(console.error("[SDK-SEGMENT] segmentation failed",c),gn(m,c))}},[ae,Un,wr,gn]),Kr=(0,e.useCallback)(()=>{if(le.current)return Promise.resolve();if(He.current)return He.current;const n=_n.current;if(!n)return Promise.resolve();const t=ke.current;let o;return o=(async()=>{(0,f.timeLog)("\u25B6 start loading paint shader textures");try{const r=await(0,Lr.preparePaintResourcesFromWorkBuffer)(n.buffer,n.cols,n.rows,i=>{if(t!==ke.current){(0,Lr.releaseFreqLayerImages)(i);return}er(i),le.current=i,Vr(!0),(0,f.timeLog)("\u25B6 paint shader textures ready"),Un()});if(t!==ke.current){r&&(r.originImage.dispose(),(0,f.releasePaintResourceLayers)(r.layers));return}if(!r)return;(0,f.releaseOriginSkImage)(Dn.current),Dn.current=r.originImage,Bt(r.originImage),(0,f.timeLog)("\u25B6 origin Skia work resolution"),le.current||(er(r.layers),le.current=r.layers,Vr(!0)),Un()}catch(r){__DEV__&&console.warn("[MaskSegment] failed to prepare paint shader textures",r)}finally{He.current===o&&(He.current=null)}})(),He.current=o,o},[Un]);St.current=Kr;const Ut=(0,e.useCallback)(async()=>{if(!(zr.current||!xe||!Re)){zr.current=!0;try{Ae();const n=le.current;n&&((0,f.releasePaintResourceLayers)(n),le.current=null,er(null)),await(0,_e.clearDerivedImageCache)(),hr.current="",await Rr(xe,Re)}catch(n){const t=n instanceof Error?n.message:String(n);gn(t,n)}finally{zr.current=!1}}},[xe,Re,Rr,gn,Ae]);(0,e.useEffect)(()=>{if(!xe||!Re)return;const n=`${xe}|${Re}`;if(!(hr.current===n||Pn.current===n))return Ae(),Pn.current=n,Rr(xe,Re).finally(()=>{Pn.current===n&&(Pn.current="")}),()=>{ke.current+=1,Pn.current===n&&(Pn.current="");const t=le.current;t&&((0,f.releasePaintResourceLayers)(t),le.current=null),Ne.current?.dispose(),Ne.current=null,U.current=[],On.current=[],De.current=!1,Fn.current=0,pe.current&&(clearTimeout(pe.current),pe.current=null),or(null)}},[xe,Re,Ae]);const Zr=(0,e.useCallback)(()=>{const n=[],t=V.current&&V.current.size>0?V.current:ee;for(const[o,r]of t){const i=U.current.find(a=>a.id===o);n.push({regionId:o,regionName:i?.name??String(o),color:r,configJson:Be.current.get(o)})}return n},[ee]),kr=(0,e.useCallback)(n=>{const t=new Map;Be.current=new Map;const o=U.current||[],r=new Map;for(const l of o)if(l&&l.name){const c=String(l.name).trim().toLowerCase();c&&!r.has(c)&&r.set(c,l.id)}const i=new Map;for(const l of n.painted||[])l&&typeof l.regionId=="number"&&l.regionName&&i.set(l.regionId,String(l.regionName));for(const l of n.painted||[]){if(!l)continue;let c=l.regionId;if(l.regionName){const m=String(l.regionName).trim().toLowerCase(),u=r.get(m);typeof u=="number"&&(c=u)}t.set(c,l.color),l.configJson&&Be.current.set(c,l.configJson)}const a=[];for(const l of n.paintHistory||[]){if(typeof l!="number")continue;const c=i.get(l);if(c){const m=r.get(String(c).trim().toLowerCase());if(typeof m=="number"){a.push(m);continue}}o.some(m=>m&&m.id===l)&&a.push(l)}V.current=t,je(t),Qe(a.length?a:[...n.paintHistory||[]]),n.currentColor&&(wt(n.currentColor),pn.current=n.currentColorConfigJson,Nr(null))},[]);(0,e.useEffect)(()=>{!re||!G||sr.current||(sr.current=!0,kr(re))},[re,G,kr]),(0,e.useEffect)(()=>()=>{pe.current&&clearTimeout(pe.current)},[]);const on=(0,e.useCallback)(()=>{De.current=!1,pe.current&&(clearTimeout(pe.current),pe.current=null),or(null)},[]),Gt=(0,e.useCallback)(()=>{const n=(0,F.getMaskSegmentRuntimeConfig)().interaction;if(!n.enableInitRegionFlash||De.current)return;const t=U.current;if(t.length===0)return;const o=Fe.current,r=o?Math.max(500,o.w*o.h*.002):500;On.current=t.filter(a=>!V.current.has(a.id)&&a.area>=r),De.current=!0,Fn.current=0;const i=()=>{if(!De.current||On.current.length===0)return;const a=On.current,l=Fn.current;if(l>=a.length){on();return}or(a[l].id),Fn.current+=1,pe.current=setTimeout(i,n.initRegionFlashMs)};i()},[]),we=(0,e.useCallback)(()=>{on()},[on]);(0,e.useEffect)(()=>{De.current&&V.current.size>0&&on()},[ee,on]),(0,e.useEffect)(()=>{if(G&&lr&&_&&Pt>0&&Lt){De.current||Gt();return}G||(on(),Wt(!1))},[G,lr,_,Pt,Lt,Gt,on]);const Jt=(0,e.useCallback)(()=>dn||(nr==null?null:dt[nr]??null),[dn,nr,dt]),Gn=dn!=null||nr!=null,qt=(0,e.useCallback)((n,t)=>{let o=!1;if(je(r=>{const i=r.get(n);if(i&&(0,f.bgrColorEquals)(i,t))return r;o=!0,Qe(l=>l[l.length-1]===n?l:[...l.filter(m=>m!==n),n]);const a=new Map(r);return a.set(n,t),V.current=a,a}),o){const r=pn.current??Be.current.get(n);pn.current&&Be.current.set(n,pn.current);const i=U.current.find(a=>a.id===n);Fr.current?.({kind:"painted",regionId:n,regionName:i?.name??String(n),color:t,configJson:r})}},[]),Jn=(0,e.useCallback)((n,t,o=!1)=>{if(!G||!ne||U.current.length===0)return null;const r=(0,f.canvasToNormalized)(n,t,me,ge,ne.w,ne.h);if(!r)return null;const i=u=>u!=null&&U.current.some(p=>p.id===u),a=Le.current,l=o?0:(0,F.getMaskSegmentRuntimeConfig)().interaction.pickMapSearchRadiusPx;if(a){const u=(0,f.lookupRegionFromPickMap)(r.x,r.y,a,0);if(i(u))return u;if(o)return null}if(!o){const u=(0,f.resolveRegionHit)(U.current,r.x,r.y);if(u!=null)return u}if(a&&!o&&l>0){const u=(0,f.lookupRegionFromPickMap)(r.x,r.y,a,l);if(i(u))return u}const c=Hr.current,m=ir.current;if(c&&m!=null){const u=cr.current,p=(0,f.pickKickRegionFromMask)(r.x,r.y,c,m,u,o);if(p!=null)return p;if(!o){const g=U.current.find(d=>d.id===m);if(g&&(0,f.pickKickNearStrip)(r.x,r.y,g))return m}}return null},[G,ne,me,ge]),Yr=(0,e.useCallback)((n,t)=>{if(we(),!G||!ne||U.current.length===0||!Gn||!Ce||Xe)return;const o=Jn(n,t);if(o==null)return;const r=Jt();r&&(qt(o,r),!Ce&&!He.current&&Kr())},[G,ne,Gn,Ce,Xe,Jn,Jt,qt,we,Kr]),jr=(0,e.useRef)(Gn),Xt=(0,e.useRef)(Xe),Kt=(0,e.useRef)(G),Fe=(0,e.useRef)(ne);(0,e.useEffect)(()=>{ye.current=me},[me]),(0,e.useEffect)(()=>{he.current=ge},[ge]),(0,e.useEffect)(()=>{jr.current=Gn},[Gn]),(0,e.useEffect)(()=>{Xt.current=Xe},[Xe]),(0,e.useEffect)(()=>{Kt.current=G},[G]),(0,e.useEffect)(()=>{Fe.current=ne},[ne]);const Qr=(0,e.useRef)(Jn),et=(0,e.useRef)(Yr),Zt=(0,e.useRef)(we),nt=(0,e.useRef)(Ae);(0,e.useEffect)(()=>{Qr.current=Jn},[Jn]),(0,e.useEffect)(()=>{et.current=Yr},[Yr]),(0,e.useEffect)(()=>{Zt.current=we},[we]),(0,e.useEffect)(()=>{nt.current=Ae},[Ae]);const rt=(0,e.useCallback)(()=>{we(),Qe(n=>{if(n.length===0)return n;const t=n[n.length-1];return je(o=>{const r=new Map(o);return r.delete(t),V.current=r,r}),Be.current.delete(t),n.slice(0,-1)})},[we]),Yt=(0,e.useCallback)(()=>{we();const n=new Map;V.current=n,je(n),Qe([]),Be.current=new Map,Tn.current=null,Ae()},[we,Ae]),jt=(0,e.useCallback)(async()=>{try{const n=Mt.current;if(!n||!We)return;let o=n.makeImageSnapshot?.();if(o||(await new Promise(a=>requestAnimationFrame(()=>a())),o=n.makeImageSnapshot?.()),!o)return;const r=o.encodeToBase64;if(typeof r!="function")return;const i=r.call(o)||"";return i.length>0?i:void 0}catch(n){console.warn("[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:",n);return}},[We]),Sr=(0,e.useCallback)(async(n,t)=>{const o=_n.current,r=Le.current;if(!o||!r)throw new Error("image not ready, cannot save");let i;n.size>0&&(i=await jt());const a=le.current,l=Ne.current,c=Dn.current??a?.lowFreqImage??null,m=!i&&c&&a&&l&&Ce?{originImage:c,paintColorMap:l,lowFreqImage:a.lowFreqImage,highFreqImage:a.highFreqImage}:void 0,u=await(0,mo.compositePaintedImage)({originBuffer:o.buffer,cols:o.cols,rows:o.rows,pickBuffer:r.buffer,paintedRegions:n,destDir:t,...i?{exportPngBase64:i}:{},shaderTextures:m,renderWidth:o.cols,renderHeight:o.rows});return Tn.current={fingerprint:(0,Zn.paintedRegionsFingerprint)(n),result:u},u},[jt,Ce]);(0,e.useEffect)(()=>{if(lt&&!(!G||!Ce)&&!(re&&!sr.current)&&ee.size!==0)return Me.current&&clearTimeout(Me.current),Me.current=setTimeout(()=>{if(Me.current=null,ar.current)return;const n=V.current;!n||n.size===0||(ar.current=!0,(async()=>{try{const t=await Sr(n);ht.current?.(t)}catch(t){console.log("[VIZ-SAVE] debounced autoExport threw (non-fatal):",t)}finally{ar.current=!1}})())},400),()=>{Me.current&&(clearTimeout(Me.current),Me.current=null)}},[lt,G,Ce,re,ee,Sr]),(0,e.useImperativeHandle)(E,()=>({reset:rt,swap:n=>{we(),At(n===void 0?t=>!t:n)},save:async n=>{const t=V.current&&V.current.size>0?V.current:ee,o=(0,Zn.paintedRegionsFingerprint)(t),r=Tn.current;if(r?.fingerprint===o&&r.result)return(0,Zn.resolveExportResultForDestDir)(r.result,n?.destDir);try{return await Sr(t,n?.destDir)}catch(i){throw console.error("[VIZ-SAVE] SDK save() composite threw:",i),i}},getLastExport:()=>Tn.current?.result??null,session:()=>({version:1,originUrl:xt.current,maskUrl:Rt.current,painted:Zr(),paintHistory:[...rr],currentColor:dn??void 0,currentColorConfigJson:pn.current,savedAt:Date.now()}),loadSession:kr,setPaintColor:(n,t)=>{wt(n),pn.current=t,Nr(null)},setMaskConfig:n=>{(0,F.setMaskSegmentRuntimeConfig)({maskConfig:n}),Br.current=(0,F.getMaskSegmentRuntimeConfig)(),xe&&Re&&(hr.current="",Rr(xe,Re))},clearAllPaint:Yt,undoSelection:rt,resegment:Ut,getRegions:()=>[...U.current],getPaintedRegions:()=>Zr(),startLasso:()=>{if(ze(new Map),Y.current=new Map,Ee(null),Z.current=null,dr(!0),Sn.current=!0,(0,F.getMaskSegmentRuntimeConfig)().mask.magneticLasso&&(0,F.getMaskSegmentRuntimeConfig)().mask.manualSplitWalls){const t=_n.current;if(t&&t.cols>0&&t.rows>0){const o=te.current,r=(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors,i=o?.wallSemanticIdx??r.findIndex(c=>c.name==="wall");let a=null;o&&i>=0&&o.cols===t.cols&&o.rows===t.rows&&(a=(0,$.buildWallAllowedMask)(o.labels,o.baseboardBinary,i));const l=(0,$.buildEnergyMap)(t.buffer,t.cols,t.rows,256,a);yr(l),tn.current=l}}},cancelLasso:()=>{dr(!1),Sn.current=!1,Ee(null),Z.current=null,ze(new Map),Y.current=new Map,yr(null),tn.current=null,nn.current=null,rn.current=null,$e.current=null,pr.current=!1,Xr(null)},endLasso:()=>{const n=te.current,t=(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors,o=n?.wallSemanticIdx??t.findIndex(s=>s.name==="wall"),r=n&&o>=0?{labels:n.labels,baseboardBinary:n.baseboardBinary,cols:n.cols,rows:n.rows,wallSemanticIdx:o}:null,i=s=>s.length<3?!1:r?(0,$.filterVerticesToWallMask)(s,r).length>=3:!0,a=Z.current;if(a&&i(a)){const s=`lasso_${++Dt.current}`,B={id:s,vertices:[...a],isClosed:!0},W=new Map(Y.current);W.set(s,B),Y.current=W,ze(W),Ee(null),Z.current=null}const l=new Map([...Y.current.entries()].filter(([,s])=>i(s.vertices)));if(l.size===0)return[...j.current];const c=U.current,m=c.find(s=>s.name==="wall")??c.find(s=>/^wall-\d+$/.test(s.name));if(!m)return[...j.current];const u=m.hex,p={...m.color},g=te.current;if(!g)return[...j.current];const{labels:d,baseboardBinary:R,cols:y,rows:k}=g,h=y*k;if((0,F.getMaskSegmentRuntimeConfig)().mask.activeContourRefine&&r)for(const[,s]of l)s.vertices=(0,fo.refinePolygonToWallEdges)(s.vertices,r);const P=(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors,w=[...l.entries()],L=w.map(([,s])=>s.vertices),M=()=>{const s=new Map;for(let D=0;DW&&(B=D,W=q);return B},b=g.indexToName??P.map(s=>s.name);let A=g.wallSemanticIdx??P.findIndex(s=>s.name==="wall");if(A<0){const s=M();s>=0&&b[s]==="wall"&&(A=s)}if(A<0)return[...j.current];const oe=(s,B)=>{z.fill(0);for(const W of Se)W.x=y,W.y=k,W.w=0,W.h=0;for(let W=0;Wue.x+ue.w&&(ue.w=oo-ue.x),so>ue.y+ue.h&&(ue.h=so-ue.y);break}}}},C=new Uint8Array(h);g.wallSubLabels&&g.wallSubLabels.length===h?C.set(g.wallSubLabels):C.fill(J.WALL_SUB_LABEL_NONE);let se=-1;for(let s=0;s/^wall-\d+$/.test(s.name));let sn=0;for(const s of ie){const B=/^wall-(\d+)$/.exec(s.name);B&&(sn=Math.max(sn,Number(B[1])))}const Ge=new Uint8Array(h);Ge.fill(J.WALL_SUB_LABEL_NONE);const z=new Array(w.length).fill(0),Se=w.map(()=>({x:y,y:k,w:0,h:0}));if(oe(A,C),z.every(s=>s===0)){const s=M();s>=0&&b[s]==="wall"&&s!==A&&(A=s,Ge.fill(J.WALL_SUB_LABEL_NONE),oe(A,C))}const an=(0,F.getMaskSegmentRuntimeConfig)().mask.manualSplitWallsGapAbsorbDilatePx??5;an>0&&w.length>0&&(0,J.absorbSmallWallGapsForLassoPolygons)(Ge,w.length,z,Se,d,R,A,C,y,k,an);const Cn=(0,F.getMaskSegmentRuntimeConfig)().mask.manualSplitWallsMaxCount??8,ln=Math.max(0,Cn-ie.length),Pr=z.map((s,B)=>({area:s,idx:B})).filter(s=>s.area>0).sort((s,B)=>B.area-s.area).slice(0,ln).map(s=>s.idx);if(Pr.length===0)return[...j.current];for(let s=0;s=0&&(C[s]=se+1+W)}const To=c.filter(s=>s.name!=="wall"&&!/^wall-\d+$/.test(s.name)),_o=c.reduce((s,B)=>Math.max(s,B.id),-1),No=Pr.map((s,B)=>{const[,W]=w[s],D=Se[s];return{id:_o+1+B,name:`wall-${sn+B+1}`,hex:u,color:p,polygons:[W.vertices.map(q=>({x:q.x,y:q.y}))],outlinePolygons:[W.vertices.map(q=>({x:q.x,y:q.y}))],bbox:{x:D.x/y,y:D.y/k,w:D.w/y,h:D.h/k},area:z[s]}}),vn=[...To,...ie,...No],to=new Map(vn.map(s=>[s.name,s.id])),qn=Le.current;let Mr;if(qn&&qn.cols===y&&qn.rows===k&&qn.buffer.length===h)Mr=(0,J.patchPickMapForManualWallSplit)(qn.buffer,d,R,A,C,to,y,k);else{const s=(0,J.buildPickMapAfterWallSplit)(d,R,A,C,b,to,y,k);Mr=(0,J.dilatePickBuffer1px)(s,y,k)}const Xn=new Map;for(const[s,B]of V.current){const W=c.find(D=>D.id===s);if(W){if(W.name==="wall"){Be.current.delete(s);continue}Xn.set(s,B)}}V.current=Xn;const Do=tr.current.filter(s=>{const B=c.find(W=>W.id===s);return B!=null&&B.name!=="wall"});U.current=vn,Le.current={buffer:Mr,cols:y,rows:k},ir.current=vn.find(s=>s.thinStrip)?.id??null,te.current={labels:g.labels,baseboardBinary:g.baseboardBinary,cols:y,rows:k,wallSubLabels:C,indexToName:b,wallSemanticIdx:A},Xn.size>0&&(Ne.current?.dispose(),Ne.current=(0,Ln.createPaintColorMapForPaint)(Mr,y,k,Xn));const Ho=new Map(vn.filter(s=>/^wall-\d+$/.test(s.name)).map(s=>[s.name,s])),Vo=Pr.map((s,B)=>{const[W,D]=w[s],q=`wall-${sn+B+1}`,Oe=Ho.get(q);return Oe?{id:W,regionId:Oe.id,regionName:Oe.name,vertices:D.vertices,bbox:Oe.bbox,area:Oe.area}:null}).filter(s=>s!=null),ot=[...j.current,...Vo];return qr(ot),j.current=ot,ur(vn),fr(vn.length),je(Xn),Qe(Do),zt(s=>s+1),Ue.current=null,br.current="",dr(!1),Sn.current=!1,Ee(null),Z.current=null,yr(null),tn.current=null,ze(new Map),Y.current=new Map,ot},getManualRegions:()=>[...j.current],deleteLasso:n=>{if(Y.current.has(n)){ze(b=>{const A=new Map(b);return A.delete(n),Y.current=A,A});return}const t=j.current.find(b=>b.id===n);if(!t)return;const o=U.current,r=te.current,i=Le.current;if(!r||!i)return;const a=/^wall-(\d+)$/.exec(t.regionName);if(!a)return;const l=Number(a[1])-1,{labels:c,baseboardBinary:m,cols:u,rows:p}=r,g=u*p,d=(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors,R=r.indexToName??d.map(b=>b.name);let y=r.wallSemanticIdx??d.findIndex(b=>b.name==="wall");if(y<0)return;const k=new Uint8Array(r.wallSubLabels?.length===g?r.wallSubLabels:new Uint8Array(g).fill(J.WALL_SUB_LABEL_NONE));for(let b=0;bb.id!==t.regionId),O=new Map(h.map(b=>[b.name,b.id])),P=(0,J.patchPickMapForManualWallSplit)(i.buffer,c,m,y,k,O,u,p),w=new Map(V.current);w.delete(t.regionId),Be.current.delete(t.regionId),V.current=w;const L=tr.current.filter(b=>b!==t.regionId);tr.current=L,U.current=h,Le.current={buffer:P,cols:u,rows:p},te.current={labels:r.labels,baseboardBinary:r.baseboardBinary,cols:u,rows:p,wallSubLabels:k,indexToName:R,wallSemanticIdx:y};const M=j.current.filter(b=>b.id!==n);j.current=M,qr(M),ur(h),fr(h.length),je(w),Qe(L),zt(b=>b+1),Ue.current=null,br.current=""}}),[rt,we,ee,rr,dn,Zr,kr,Yt,Ut,Sr]);const[Wo,Mn]=(0,e.useState)(new Map);(0,e.useEffect)(()=>{if(!G||!_||en.length===0){G||(Mn(new Map),wn(!1),Ue.current=null);return}const n=te.current;if(!n){Mn(new Map),wn(!1);return}const t=en.map(a=>`${a.id}:${a.name}`).join("|");if(br.current===t&&Ue.current&&(0,f.rectsEqual)(Ue.current,_))return;const o=__DEV__?performance.now():0,r=(0,de.downsampleMaskDataForPaths)(n,(0,F.getMaskSegmentRuntimeConfig)().pipeline.maskPathMaxLongSide),i=(0,de.buildAllRegionOutlinePaths)(en,r,_);Ue.current=_,br.current=t,Mn(i),wn(!0),Rn.current=!0,wr()},[G,_,en,wr]);const ts=(0,e.useMemo)(()=>{if(yn==null||Dr==null||!_||en.length===0)return null;const n=te.current;if(!n)return null;const t=(0,de.downsampleMaskDataForPaths)(n,(0,F.getMaskSegmentRuntimeConfig)().pipeline.maskPathMaxLongSide);return(0,de.buildRegionOutlinePathForRegion)(yn,en,t,_,Dr)},[yn,Dr,_,en]),Eo=(n,t=1)=>!n||!_?null:(0,I.jsx)(S.Image,{image:n,x:_.x,y:_.y,width:_.w,height:_.h,opacity:t}),Qt=(n,t)=>{const o=Wo.get(n);return!o||!_?null:(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(S.Path,{path:o,color:pt.regionOverlayFill,style:"fill",opacity:.05},`${t}-fill-${n}`),(0,I.jsx)(S.Path,{path:o,color:pt.regionOverlayFill,style:"stroke",strokeWidth:3,strokeJoin:"round",antiAlias:!0,children:(0,I.jsx)(S.DashPathEffect,{intervals:[8,6]})},`${t}-stroke-${n}`)]})},Ao=(0,e.useMemo)(()=>{if(!(Hn<=1))return(0,f.buildZoomPanMatrix)(Vn.x,Vn.y,Hn,me,ge)},[Hn,Vn,me,ge]),Bo=()=>{const n=mr??Ve;if(!n||!_)return null;const t=!Et&&G,o=xr&&Ve&&gr&&Ce,r=!Et&&ee.size>0&&o,i=mr??Ve;return(0,I.jsxs)(I.Fragment,{children:[(0,I.jsx)(S.Rect,{x:0,y:0,width:me,height:ge,color:"#F0F1F3"}),(0,I.jsx)(S.Group,{clip:S.Skia.XYWHRect(0,0,me,ge),children:(0,I.jsxs)(S.Group,{matrix:Ao,children:[r&&i?(0,I.jsx)(Ln.PaintShaderLayer,{originImage:i,paintColorMap:xr,lowFreqImage:Ve,highFreqImage:gr,x:_.x,y:_.y,width:_.w,height:_.h,showOrigin:!1}):Eo(n),t&&kt!=null&&Qt(kt,"init-overlay"),t&&yn!=null&&!ee.has(yn)&&Qt(yn,"hold-overlay"),kn&&ne&&_&&(()=>{const l=[],c=ne.w,m=ne.h,u=_,p=(h,O)=>({x:u.x+h*u.w,y:u.y+O*u.h}),g=tn.current?Ro:xo,d=te.current,R=d?.wallSemanticIdx??(0,F.getMaskSegmentRuntimeConfig)().mask.semanticColors.findIndex(h=>h.name==="wall"),y=d&&R>=0?{labels:d.labels,baseboardBinary:d.baseboardBinary,cols:d.cols,rows:d.rows,wallSemanticIdx:R}:null,k=h=>y?(0,$.filterVerticesToWallMask)(h,y):h;for(const[,h]of Jr){const O=k(h.vertices);if(!h.isClosed||O.length<3)continue;const P=S.Skia.Path.Make(),w=p(O[0].x,O[0].y);P.moveTo(w.x,w.y);for(let L=1;L0){const h=k(zn);if(h.length>0){const O=S.Skia.Path.Make(),P=p(h[0].x,h[0].y);O.moveTo(P.x,P.y);for(let w=1;w=3&&O.lineTo(P.x,P.y),l.push((0,I.jsx)(S.Path,{path:O,color:g,style:"stroke",strokeWidth:2.5,strokeJoin:"round",antiAlias:!0,children:(0,I.jsx)(S.DashPathEffect,{intervals:[10,6]})},"lasso-current"));for(let w=0;w=3&&!M,A=M?7:b?6:4,oe=S.Skia.Path.Make(),C=S.Skia.XYWHRect(L.x-A,L.y-A,A*2,A*2);if(oe.addOval(C),l.push((0,I.jsx)(S.Path,{path:oe,color:M?"#FFFFFF":g,style:"fill",antiAlias:!0},`lasso-dot-cur-${w}`)),M||b){const se=S.Skia.Path.Make(),ie=b?A+5:A;se.addOval(S.Skia.XYWHRect(L.x-ie,L.y-ie,ie*2,ie*2)),l.push((0,I.jsx)(S.Path,{path:se,color:g,style:"stroke",strokeWidth:b?1.5:2,opacity:b?.65:1,antiAlias:!0},`lasso-dot-cur-ring-${w}`))}}}}return l})()]})})]})},Fo=()=>{const n=We;if(!n)return null;const t=n.w,o=n.h,r=xr&&Ve&&gr&&Ce,i=ee.size>0&&r,a=mr??Ve;if(i&&a)return(0,I.jsx)(Ln.PaintShaderLayer,{originImage:a,paintColorMap:xr,lowFreqImage:Ve,highFreqImage:gr,x:0,y:0,width:t,height:o,showOrigin:!1});const l=mr??Ve;return l?(0,I.jsx)(S.Image,{image:l,x:0,y:0,width:t,height:o}):null},eo=(0,e.useMemo)(()=>{const n=(r,i)=>{Zt.current?.();const a=(0,f.screenToCanvasCoords)(r,i,ye.current,he.current,ce.current,be.current),l=Qr.current(a.x,a.y,!0);if(l==null||!Fe.current){hn(null),bn(null);return}if(V.current&&V.current.has(l)){hn(null),bn(null);return}const c=(0,f.canvasToNormalized)(a.x,a.y,ye.current,he.current,Fe.current.w,Fe.current.h);hn(l),bn(c)},t=(r,i,a)=>{if(!a)return;const l=(0,f.screenToCanvasCoords)(r,i,ye.current,he.current,ce.current,be.current);if(jr.current)et.current(l.x,l.y);else{if(we(),Xt.current||!Kt.current||!Fe.current)return;const c=Qr.current(l.x,l.y);if(c==null)return;const m=U.current.find(u=>u.id===c);Fr.current?.({kind:"brush_required",hint:"please select a brush color first",regionId:c,regionName:m?.name??String(c)})}},o=(r,i)=>{if(jr.current){hn(null),bn(null);return}const a=(0,f.screenToCanvasCoords)(r,i,ye.current,he.current,ce.current,be.current);et.current(a.x,a.y),hn(null),bn(null)};return ve.Gesture.Tap().onBegin(r=>{"worklet";(0,fe.runOnJS)(n)(r.x,r.y)}).onEnd((r,i)=>{"worklet";(0,fe.runOnJS)(t)(r.x,r.y,i)}).onFinalize(r=>{"worklet";(0,fe.runOnJS)(o)(r.x,r.y)})},[]),tt=(0,e.useMemo)(()=>{const n=(r,i)=>{Ot.current=ce.current,Tt.current={...be.current},_t.current={x:r,y:i}},t=(r,i,a)=>{const l=ye.current,c=he.current;if(l<=0||c<=0)return;const m=l/2,u=c/2,p=Ot.current,g=Tt.current,d=_t.current;let R=Math.max(1,Math.min(p*r,5));const y=(d.x-g.x-m)/p+m,k=(d.y-g.y-u)/p+u;let h={x:i-m-R*(y-m),y:a-u-R*(k-u)};R<=1?(R=1,h={x:0,y:0}):h=(0,f.clampPanOffset)(h,R,l,c,Gr.current),Ft(R),$r(h),ce.current=R,be.current=h},o=()=>{ce.current<=1.01&&nt.current?.()};return ve.Gesture.Pinch().onStart(r=>{"worklet";(0,fe.runOnJS)(n)(r.focalX,r.focalY)}).onUpdate(r=>{"worklet";(0,fe.runOnJS)(t)(r.scale,r.focalX,r.focalY)}).onEnd(()=>{"worklet";(0,fe.runOnJS)(o)()})},[]),no=(0,e.useMemo)(()=>{const n=()=>{Sn.current&&nn.current||(Ur.current={...be.current})},t=(r,i)=>{if(ce.current<=1)return;const a=(0,f.clampPanOffset)({x:Ur.current.x+r,y:Ur.current.y+i},ce.current,ye.current,he.current,Gr.current);$r(a),be.current=a},o=()=>{ce.current<=1.01&&nt.current?.()};return ve.Gesture.Pan().minPointers(1).maxPointers(1).minDistance(10).onStart(()=>{"worklet";(0,fe.runOnJS)(n)()}).onUpdate(r=>{"worklet";(0,fe.runOnJS)(t)(r.translationX,r.translationY)}).onEnd(()=>{"worklet";(0,fe.runOnJS)(o)()})},[]),ro=(0,e.useMemo)(()=>{const n=(a,l)=>{const c=nn.current;if(!c)return;const m=te.current,u=lo(m),p=u?(0,$.resolveLassoWallDragPoint)(a,l,u,Mo):{x:a,y:l};if(!p)return;const g=c.kind==="closed"?c.polyId:void 0;if(!vr(p.x,p.y,m)&&!Ir(p.x,p.y,j.current,Y.current,g)){if(pr.current=!0,c.kind==="open"){Ee(d=>{if(!d||c.vertexIndex>=d.length)return d;const R=[...d];return R[c.vertexIndex]=p,Z.current=R,R});return}c.kind==="closed"&&c.polyId&&ze(d=>{const R=d.get(c.polyId);if(!R||c.vertexIndex>=R.vertices.length)return d;const y=new Map(d),k=[...R.vertices];return k[c.vertexIndex]=p,y.set(c.polyId,{...R,vertices:k}),Y.current=y,y})}},t=(a,l)=>{const c=ye.current,m=he.current;if(c<=0||m<=0)return;const u=(0,f.screenToCanvasCoords)(a,l,c,m,ce.current,be.current),p=Fe.current;if(!p)return;const g=(0,f.canvasToNormalized)(u.x,u.y,c,m,p.w,p.h);if(!g)return;const d=lo(te.current),R=te.current,y=d?(0,$.snapNormPointToWallCornerOrEdge)(g.x,g.y,d,Co):g,k=tn.current,h=k!=null;if(Z.current&&Z.current.length>=3&&Zo(u.x,u.y,c,m,p.w,p.h,Z.current,ko)){const P=Z.current;if(!P||P.length<3||jo(P,R,j.current,Y.current))return;const w=`lasso_${++Dt.current}`,L={id:w,vertices:[...P],isClosed:!0};ze(M=>{const b=new Map(M);return b.set(w,L),Y.current=b,b}),Ee(null),Z.current=null;return}if(!co(y.x,y.y,R,d,j.current,Y.current))return;const O=Z.current;if(O&&O.length>0){for(let w=0;w0){const w=P[P.length-1],L=(0,$.normToEnergyPoint)(w.x,w.y,k),M=(0,$.normToEnergyPoint)(y.x,y.y,k),b=(0,$.findShortestPath)(k.map,k.w,k.h,L.x,L.y,M.x,M.y,k.traversable),A=(0,$.extractCornerPoints)(b,4,1),oe=(0,$.energyPointsToNorm)(A,k).filter(C=>co(C.x,C.y,R,d,j.current,Y.current)).filter((C,se)=>{if(se>0)return!0;const ie=P[P.length-1];return Math.hypot(C.x-ie.x,C.y-ie.y)>5e-4});if(oe.length===0)return;Ee(C=>{if(!C)return oe;const se=[...C,...oe];return Z.current=se,se});return}}Ee(P=>{const w=P?[...P,y]:[y];return Z.current=w,w})},o=(a,l)=>{rn.current={x:a,y:l},pr.current=!1,$e.current=null;const c=ye.current,m=he.current,u=Fe.current;if(!u||c<=0||m<=0)return;const p=(0,f.screenToCanvasCoords)(a,l,c,m,ce.current,be.current),g=Z.current,d=g!=null&&g.length>0,R=Yo(p.x,p.y,c,m,u.w,u.h,Ht,g,Y.current,{openOnly:d});R&&($e.current=R)},r=(a,l)=>{if(nn.current){const p=ye.current,g=he.current,d=Fe.current;if(!d||p<=0||g<=0)return;const R=(0,f.screenToCanvasCoords)(a,l,p,g,ce.current,be.current),y=(0,f.canvasToNormalized)(R.x,R.y,p,g,d.w,d.h);if(!y)return;n(y.x,y.y);return}const c=rn.current;if(!c)return;const m=Math.hypot(a-c.x,l-c.y);if(m>vo&&!$e.current){rn.current=null;return}const u=$e.current;!u||m{if(nn.current){nn.current=null,pr.current=!1,$e.current=null,Xr(null);return}$e.current=null;const c=rn.current;rn.current=null,c&&t(c.x,c.y)};return ve.Gesture.Pan().minPointers(1).maxPointers(1).minDistance(0).onBegin(a=>{"worklet";(0,fe.runOnJS)(o)(a.x,a.y)}).onUpdate(a=>{"worklet";(0,fe.runOnJS)(r)(a.x,a.y)}).onEnd(a=>{"worklet";(0,fe.runOnJS)(i)(a.x,a.y)}).onFinalize(a=>{"worklet";(0,fe.runOnJS)(i)(a.x,a.y)})},[]),Oo=(0,e.useMemo)(()=>kn?ve.Gesture.Simultaneous(tt,ro):ve.Gesture.Exclusive(ve.Gesture.Simultaneous(tt,no),eo),[kn,eo,ro,tt,no]);return(0,I.jsxs)(Je.View,{style:[io.container,Wr],children:[(0,I.jsx)(Je.View,{style:io.canvasWrap,onLayout:n=>{const{width:t,height:o}=n.nativeEvent.layout;Io(t,o)},children:wo?(0,I.jsx)(ve.GestureDetector,{gesture:Oo,children:(0,I.jsx)(Je.View,{style:{width:me,height:ge},children:(0,I.jsx)(S.Canvas,{style:{width:me,height:ge},pointerEvents:"none",children:Bo()})})},kn?"lasso":"paint"):null}),ho&&We?(0,I.jsx)(Je.View,{style:{position:"absolute",left:-We.w-200,top:0,width:We.w,height:We.h,opacity:0,overflow:"hidden"},pointerEvents:"none",children:(0,I.jsx)(S.Canvas,{ref:Mt,style:{width:We.w,height:We.h},pointerEvents:"none",children:(0,I.jsx)(S.Group,{children:Fo()})})}):null]})}),io=Je.StyleSheet.create({container:{flex:1,backgroundColor:"#fff"},canvasWrap:{flex:1,width:"100%",alignSelf:"stretch",position:"relative",backgroundColor:"#f5f5f5",borderWidth:Je.StyleSheet.hairlineWidth,borderColor:"#ddd",overflow:"hidden"}});var es=Qo; diff --git a/dist/components/MaskSegmentCanvas.types.d.ts b/dist/components/MaskSegmentCanvas.types.d.ts index 2319a3a..643be08 100644 --- a/dist/components/MaskSegmentCanvas.types.d.ts +++ b/dist/components/MaskSegmentCanvas.types.d.ts @@ -53,6 +53,33 @@ export type MaskSegmentConfig = { splitWallsChromaBlurRadius?: number; /** wall mask only, low saturation (white/gray wall) junction radius, used to force separate colored walls */ splitWallsNeutralChromaMax?: number; + /** + * Wall split: raw per-channel BGR Sobel gradient threshold for edge barriers + * (0 = disabled). Uses un-normalized 8โ€‘bit Sobel magnitude on each B/G/R + * channel (max per pixel, range โ‰ˆ 0โ€“1442). Visible wall seams โ‰ˆ 120โ€“280, + * subtle lighting gradients โ‰ˆ 20โ€“80. Default: 160. + */ + splitWallsEdgeBarrierThreshold?: number; + /** + * Morphological close radius for wall mask holes before component labeling. + * Non-wall pixels inside the wall boundary (windows, doors, mask artefacts) + * are temporarily filled so the BFS can bridge across them. Default: 3. + * Set to 0 to disable. + */ + splitWallsCloseMaskRadius?: number; + /** When true, disables automatic texture-based wall splitting (splitWalls). Manual lasso partitioning is used instead. */ + manualSplitWalls?: boolean; + /** wall mask only, max number of manual wall sub-regions defined by lasso */ + manualSplitWallsMaxCount?: number; + /** + * Manual lasso: morphological dilation radius (seg pixels) used to merge thin + * unassigned wall pockets adjacent to the drawn polygon. + */ + manualSplitWallsGapAbsorbDilatePx?: number; + /** When true, lasso mode uses edge-snapping (Sobel gradient + Dijkstra shortest-path). Default: false. */ + magneticLasso?: boolean; + /** After End Lasso, run active contour refinement on each polygon to expand vertices outward toward wall-mask edges. Default: false. */ + activeContourRefine?: boolean; }; export type PaintConfig = { palette?: BgrColor[]; @@ -117,6 +144,32 @@ export type PaintBrushRequiredPayload = { regionName: string; }; export type PaintCallbackPayload = PaintSuccessPayload | PaintBrushRequiredPayload; +/** A lasso polygon defined by user tapping vertices on the wall mask area. Vertices are in normalized image coordinates (0..1). */ +export type LassoPolygon = { + id: string; + vertices: { + x: number; + y: number; + }[]; + isClosed: boolean; +}; +/** Result of converting a lasso polygon into an actual wall sub-region. */ +export type ManualWallPartition = { + id: string; + regionId: number; + regionName: string; + vertices: { + x: number; + y: number; + }[]; + bbox: { + x: number; + y: number; + w: number; + h: number; + }; + area: number; +}; export type MaskSegmentCanvasRef = { reset: () => void; swap: (showOrigin?: boolean) => void; @@ -133,6 +186,20 @@ export type MaskSegmentCanvasRef = { getPaintedRegions: () => PaintedRegionRecord[]; /** Returns the most recent auto-export or save() result, if any. */ getLastExport?: () => SavePaintResult | null; + /** Enter lasso mode โ€” user can tap wall mask area to place polygon vertices. */ + startLasso: () => void; + /** Exit lasso mode, convert all closed lasso polygons into wall-X sub-regions for painting. Returns the partition results. */ + endLasso: () => ManualWallPartition[]; + /** + * Exit the current lasso editing session without saving regions. + * Discards in-progress vertices and closed polygons from this session only; + * previously committed manual wall partitions are kept. + */ + cancelLasso: () => void; + /** Get the current manual wall partitions (only valid after endLasso has been called). */ + getManualRegions: () => ManualWallPartition[]; + /** Delete a lasso polygon by its id. Committed partitions also drop paint on that region. */ + deleteLasso: (id: string) => void; }; export type MaskSegmentCanvasProps = { originUrl?: string; diff --git a/dist/components/MaskSegmentCanvas.types.js b/dist/components/MaskSegmentCanvas.types.js index c0e9e90..77b6327 100644 --- a/dist/components/MaskSegmentCanvas.types.js +++ b/dist/components/MaskSegmentCanvas.types.js @@ -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); diff --git a/dist/index.d.ts b/dist/index.d.ts index 2d33ebe..39d5638 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,5 +1,5 @@ export { default } from './components/MaskSegmentCanvas'; -export type { BgrColor, InteractionConfig, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types'; +export type { BgrColor, InteractionConfig, LassoPolygon, ManualWallPartition, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, MaskSegmentSession, MaskSegmentWatchDetail, MaskSegmentWatchState, MaskSemanticColor, PaintBrushRequiredPayload, PaintCallbackPayload, PaintSuccessPayload, PaintConfig, PaintedRegionRecord, PipelineConfig, PipelinePreset, SavePaintOptions, SavePaintResult, SegmentRegion, } from './components/MaskSegmentCanvas.types'; export { BASEBOARD_SEMANTIC_NAME, MASK_SEMANTIC_COLORS, } from './utils/maskSemanticPalette'; export { createRuntimeConfig, DEFAULT_INTERACTION_CONFIG, DEFAULT_MASK_CONFIG, DEFAULT_PAINT_CONFIG, DEFAULT_PIPELINE_CONFIG, PIPELINE_HIGH, PIPELINE_LOW, PIPELINE_MEDIUM, PIPELINE_PRESETS, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from './utils/maskSegmentRuntime'; export { prewarmPngBgrCache, prewarmPngBgrCacheAsync, } from './utils/pngImage'; diff --git a/dist/index.js b/dist/index.js index 41b4074..15e9708 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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"); diff --git a/dist/utils/activeContour.d.ts b/dist/utils/activeContour.d.ts new file mode 100644 index 0000000..c834e9a --- /dev/null +++ b/dist/utils/activeContour.d.ts @@ -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; +}[]; diff --git a/dist/utils/activeContour.js b/dist/utils/activeContour.js new file mode 100644 index 0000000..7a2f65e --- /dev/null +++ b/dist/utils/activeContour.js @@ -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=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=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;t1)for(let f=1;f0?l[l.length-1]:t[(a-1+x)%x],y],o);A0?l[l.length-1]:t[(a-1+x)%x],y],o);F5e-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;yg&&(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;r0){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} diff --git a/dist/utils/magneticLasso.d.ts b/dist/utils/magneticLasso.d.ts new file mode 100644 index 0000000..7f445bc --- /dev/null +++ b/dist/utils/magneticLasso.d.ts @@ -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(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; +}[]; diff --git a/dist/utils/magneticLasso.js b/dist/utils/magneticLasso.js new file mode 100644 index 0000000..a32137d --- /dev/null +++ b/dist/utils/magneticLasso.js @@ -0,0 +1 @@ +"use strict";var P=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var L=Object.getOwnPropertyNames;var V=Object.prototype.hasOwnProperty;var R=(e,t,n)=>t in e?P(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Y=(e,t)=>{for(var n in t)P(e,n,{get:t[n],enumerable:!0})},j=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of L(t))!V.call(e,r)&&r!==n&&P(e,r,{get:()=>t[r],enumerable:!(o=O(t,r))||o.enumerable});return e};var z=e=>j(P({},"__esModule",{value:!0}),e);var F=(e,t,n)=>R(e,typeof t!="symbol"?t+"":t,n);var lt={};Y(lt,{buildEnergyMap:()=>tt,buildWallAllowedMask:()=>Z,energyPointsToNorm:()=>st,extractCornerPoints:()=>at,filterVerticesToWallMask:()=>H,findShortestPath:()=>ot,isNormPointOnWallMask:()=>C,normToEnergyPoint:()=>it,resolveLassoWallDragPoint:()=>Q,snapNormPointToWallCornerOrEdge:()=>q,snapNormPointToWallEdge:()=>K,upscalePath:()=>ut});module.exports=z(lt);function C(e,t,n){const{labels:o,baseboardBinary:r,cols:c,rows:l,wallSemanticIdx:a}=n;if(a<0||c<=0||l<=0)return!1;const f=Math.min(c-1,Math.max(0,Math.floor(e*c))),h=Math.min(l-1,Math.max(0,Math.floor(t*l)))*c+f;return r[h]?!1:o[h]===a}function H(e,t){return e.filter(n=>C(n.x,n.y,t))}function v(e,t,n){const{labels:o,baseboardBinary:r,cols:c,rows:l,wallSemanticIdx:a}=e;if(t<0||n<0||t>=c||n>=l||a<0)return!1;const f=n*c+t;return r[f]?!1:o[f]===a}function G(e,t,n){if(!v(e,t,n))return!1;const{cols:o,rows:r}=e;return t===0||n===0||t===o-1||n===r-1?!0:!v(e,t-1,n)||!v(e,t+1,n)||!v(e,t,n-1)||!v(e,t,n+1)}function J(e,t,n){if(!G(e,t,n))return!1;const o=!v(e,t-1,n),r=!v(e,t+1,n),c=!v(e,t,n-1),l=!v(e,t,n+1);return(o||r)&&(c||l)}function K(e,t,n,o=12){return B(e,t,n,o,"edge")??{x:e,y:t}}function q(e,t,n,o=16){const r=B(e,t,n,o,"corner");return r||(B(e,t,n,o,"edge")??{x:e,y:t})}function Q(e,t,n,o=12){const r=q(e,t,n,o);return C(r.x,r.y,n)?r:C(e,t,n)?{x:e,y:t}:null}function B(e,t,n,o,r){const{cols:c,rows:l}=n;if(c<=0||l<=0)return null;const a=e*c,f=t*l,y=Math.floor(a),h=Math.floor(f),p=Math.max(1,Math.ceil(o)),M=o*o;let g=1/0,A=-1,s=-1;for(let i=-p;i<=p;i++)for(let b=-p;b<=p;b++){const u=y+b,m=h+i;if(u<0||m<0||u>=c||m>=l||!G(n,u,m)||r==="corner"&&!J(n,u,m))continue;const d=(a-(u+.5))**2+(f-(m+.5))**2;d<=M&&do?o/c:1,a=Math.max(1,Math.floor(t*l)),f=Math.max(1,Math.floor(n*l)),y=a*f,h=new Float32Array(y);for(let s=0;sM&&(M=U)}if(r&&r.length===t*n)for(let s=0;s0;A&&(A[b]=x?1:0),g[b]=x?1/(1+4*(p[b]/M)):1}return{map:g,w:a,h:f,scale:l,traversable:A}}const nt=[[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]],N=1e4;class et{constructor(){F(this,"data",[])}push(t,n){this.data.push({idx:t,dist:n}),this.bubbleUp(this.data.length-1)}pop(){if(this.data.length===0)return;const t=this.data[0],n=this.data.pop();return this.data.length>0&&(this.data[0]=n,this.bubbleDown(0)),t}get length(){return this.data.length}bubbleUp(t){for(;t>0;){const n=t-1>>1;if(this.data[n].dist<=this.data[t].dist)break;[this.data[n],this.data[t]]=[this.data[t],this.data[n]],t=n}}bubbleDown(t){const n=this.data.length;for(;;){let o=t;const r=2*t+1,c=2*t+2;if(rMath.max(1,Math.min(t-2,Math.round(m))),y=m=>Math.max(1,Math.min(n-2,Math.round(m))),h=f(o),p=y(r),M=f(c),g=y(l),A=t*n,s=p*t+h,i=new Uint32Array(A);i.fill(rt),i[s]=0;const b=new Int32Array(A);b.fill(-1);const u=new et;for(u.push(s,0);u.length>0;){const m=u.pop(),x=m.idx,d=m.dist;if(d>i[x])continue;const E=x%t,W=Math.floor(x/t);if(E===M&&W===g){const w=[];let S=x;for(;S>=0;)w.push({x:S%t,y:Math.floor(S/t)}),S=b[S];return w.reverse(),w}for(const[w,S]of nt){const T=E+w,I=W+S;if(T<0||T>=t||I<0||I>=n)continue;const U=I*t+T;if(a&&a[U]===0)continue;const _=Math.round(w!==0&&S!==0?e[U]*N*1.4142:e[U]*N),D=d+_;Ds&&(s=u,i=b)}s>n&&(o[i]=1,r(l,i),r(i,a))}r(0,e.length-1);const c=[];for(let l=0;l0){const a=c[c.length-1];if(Math.hypot(e[l].x-a.x,e[l].y-a.y)({x:Math.min(1,Math.max(0,n.x/t.w)),y:Math.min(1,Math.max(0,n.y/t.h))}))}function ut(e,t,n,o){return e.map(r=>({x:Math.min(n-1,Math.max(0,Math.round(r.x/t))),y:Math.min(o-1,Math.max(0,Math.round(r.y/t)))}))} diff --git a/dist/utils/maskOutlinePaths.d.ts b/dist/utils/maskOutlinePaths.d.ts index f31fd50..3d6b3a3 100644 --- a/dist/utils/maskOutlinePaths.d.ts +++ b/dist/utils/maskOutlinePaths.d.ts @@ -1,5 +1,6 @@ import { type SkPath } from '@shopify/react-native-skia'; import type { SegmentRegion, RegionMaskData } from './maskSegmentation'; +export declare function floodFillComponent(binary: Uint8Array, cols: number, rows: number, seedX: number, seedY: number): Uint8Array | null; export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: { x: number; y: number; diff --git a/dist/utils/maskOutlinePaths.js b/dist/utils/maskOutlinePaths.js index 70901be..950f8be 100644 --- a/dist/utils/maskOutlinePaths.js +++ b/dist/utils/maskOutlinePaths.js @@ -1 +1 @@ -"use strict";var k=Object.defineProperty;var S=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var U=Object.prototype.hasOwnProperty;var $=(t,n)=>{for(var i in n)k(t,i,{get:n[i],enumerable:!0})},L=(t,n,i,e)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of I(n))!U.call(t,r)&&r!==i&&k(t,r,{get:()=>n[r],enumerable:!(e=S(n,r))||e.enumerable});return t};var E=t=>L(k({},"__esModule",{value:!0}),t);var z={};$(z,{buildAllRegionOutlinePaths:()=>Y,buildRegionOutlinePathForRegion:()=>X});module.exports=E(z);var p=require("@shopify/react-native-skia"),R=require("./maskSegmentRuntime");function P(t,n,i,e,r){return e>=0&&e=0&&r0}function O(t,n,i){const e=[];for(let r=0;r`${u.x0},${u.y0}->${u.x1},${u.y1}`;for(const u of t){const o=`${u.x0},${u.y0}`,s=n.get(o);s?s.push(u):n.set(o,[u])}const e=new Set,r=[];for(const u of t){const o=i(u);if(e.has(o))continue;const s=[{x:u.x0,y:u.y0}];let a=u;for(e.add(o),s.push({x:a.x1,y:a.y1});;){const f=`${a.x1},${a.y1}`,c=`${s[0].x},${s[0].y}`;if(f===c&&s.length>2)break;const h=n.get(f)?.find(d=>!e.has(i(d)));if(!h||(a=h,e.add(i(a)),s.push({x:a.x1,y:a.y1}),s.length>t.length+1))break}s.length>=4&&r.push(s)}return r}function C(t){if(t.length<=3)return t;const n=[t[0]];for(let i=1;ii&&(i=f,e=s)}if(i>n){const s=G(t.slice(0,e+1),n),a=G(t.slice(e),n);return[...s.slice(0,-1),...a]}return[u,o]}function K(t,n,i,e){const r=p.Skia.Path.Make();for(const u of t){const o=C(u),s=G(o,1);if(s.length<2)continue;const[a,...f]=s;r.moveTo(e.x+a.x/n*e.w,e.y+a.y/i*e.h);for(const c of f)r.lineTo(e.x+c.x/n*e.w,e.y+c.y/i*e.h);r.close()}return r}function v(t,n,i){let e=!1;for(let r=0,u=i.length-1;rn!=f>n&&t<(a-o)*(n-s)/(f-s+Number.EPSILON)+o&&(e=!e)}return e}function g(t){if(t.length===0)return 0;let n=t[0].x,i=t[0].x,e=t[0].y,r=t[0].y;for(const u of t)n=Math.min(n,u.x),i=Math.max(i,u.x),e=Math.min(e,u.y),r=Math.max(r,u.y);return(i-n)*(r-e)}function D(t,n,i,e){if(t.length===0)return t;const r=Math.max(16,Math.floor(n*i*5e-5)),u=t.filter(a=>g(a)>=r),o=u.length>0?u:t;if(e){const a=e.x+.5,f=e.y+.5,c=o.filter(x=>v(a,f,x));if(c.length>0)return c.sort((x,h)=>g(h)-g(x)),c}o.sort((a,f)=>g(f)-g(a));const s=g(o[0])*.05;return o.filter(a=>g(a)>=s)}function F(t,n,i,e,r){if(e<0||r<0||e>=n||r>=i||!t[r*n+e])return null;const u=new Uint8Array(n*i),o=[r*n+e];for(u[o[0]]=255;o.length>0;){const s=o.pop(),a=s%n,f=(s-a)/n;if(a>0){const c=s-1;t[c]&&!u[c]&&(u[c]=255,o.push(c))}if(a+10){const c=s-n;t[c]&&!u[c]&&(u[c]=255,o.push(c))}if(f+10;){const y=d.pop();c+=1;const l=y%n,b=(y-l)/n;if(x+=l,h+=b,l>0){const m=y-1;t[m]&&!e[m]&&(e[m]=1,d.push(m))}if(l+10){const m=y-n;t[m]&&!e[m]&&(e[m]=1,d.push(m))}if(b+1r&&(r=c,u={x:Math.floor(x/c),y:Math.floor(h/c)})}}return u}function w(t,n,i,e,r){let u=t;if(r){const f=F(t,n,i,r.x,r.y);if(!f)return p.Skia.Path.Make();u=f}const o=O(u,n,i),s=B(o),a=D(s,n,i,r);return K(a,n,i,e)}function T(t,n,i,e){return e?{x:Math.min(n-1,Math.max(0,Math.floor(e.x*n))),y:Math.min(i-1,Math.max(0,Math.floor(e.y*i)))}:N(t,n,i)??void 0}function X(t,n,i,e,r){const o=A(n,i).get(t);if(!o)return p.Skia.Path.Make();const{cols:s,rows:a}=i,f=T(o,s,a,r);return w(o,s,a,e,f)}function A(t,n){const{labels:i,baseboardBinary:e,cols:r,rows:u,wallSubLabels:o}=n,s=r*u,a=new Map,f=(0,R.getMaskSegmentRuntimeConfig)().mask.semanticColors,c=new Int32Array(f.length);c.fill(-1);const x=new Map;let h=null;for(const l of t){if(a.set(l.id,new Uint8Array(s)),l.thinStrip){h=l.id;continue}const b=/^wall-(\d+)$/.exec(l.name);if(b&&o){x.set(Number(b[1])-1,l.id);continue}const m=f.findIndex(M=>M.name===l.name);m>=0&&(c[m]=l.id)}const d=f.length,y=f.findIndex(l=>l.name==="wall");for(let l=0;l0){a.get(h)[l]=255;continue}if(o&&y>=0&&i[l]===y){const m=o[l];if(m!==255){const M=x.get(m);M!==void 0&&(a.get(M)[l]=255)}continue}const b=i[l];b=0&&(a.get(c[b])[l]=255)}return a}function Y(t,n,i){const{cols:e,rows:r}=n,u=A(t,n),o=new Map;for(const s of t){const a=u.get(s.id);if(!a){o.set(s.id,p.Skia.Path.Make());continue}o.set(s.id,w(a,e,r,i))}return o} +"use strict";var k=Object.defineProperty;var I=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var $=Object.prototype.hasOwnProperty;var L=(t,n)=>{for(var i in n)k(t,i,{get:n[i],enumerable:!0})},E=(t,n,i,e)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of U(n))!$.call(t,r)&&r!==i&&k(t,r,{get:()=>n[r],enumerable:!(e=I(n,r))||e.enumerable});return t};var O=t=>E(k({},"__esModule",{value:!0}),t);var z={};L(z,{buildAllRegionOutlinePaths:()=>Y,buildRegionOutlinePathForRegion:()=>X,floodFillComponent:()=>S});module.exports=O(z);var p=require("@shopify/react-native-skia"),R=require("./maskSegmentRuntime");function P(t,n,i,e,r){return e>=0&&e=0&&r0}function B(t,n,i){const e=[];for(let r=0;r`${u.x0},${u.y0}->${u.x1},${u.y1}`;for(const u of t){const o=`${u.x0},${u.y0}`,s=n.get(o);s?s.push(u):n.set(o,[u])}const e=new Set,r=[];for(const u of t){const o=i(u);if(e.has(o))continue;const s=[{x:u.x0,y:u.y0}];let a=u;for(e.add(o),s.push({x:a.x1,y:a.y1});;){const f=`${a.x1},${a.y1}`,c=`${s[0].x},${s[0].y}`;if(f===c&&s.length>2)break;const h=n.get(f)?.find(d=>!e.has(i(d)));if(!h||(a=h,e.add(i(a)),s.push({x:a.x1,y:a.y1}),s.length>t.length+1))break}s.length>=4&&r.push(s)}return r}function j(t){if(t.length<=3)return t;const n=[t[0]];for(let i=1;ii&&(i=f,e=s)}if(i>n){const s=G(t.slice(0,e+1),n),a=G(t.slice(e),n);return[...s.slice(0,-1),...a]}return[u,o]}function v(t,n,i,e){const r=p.Skia.Path.Make();for(const u of t){const o=j(u),s=G(o,1);if(s.length<2)continue;const[a,...f]=s;r.moveTo(e.x+a.x/n*e.w,e.y+a.y/i*e.h);for(const c of f)r.lineTo(e.x+c.x/n*e.w,e.y+c.y/i*e.h);r.close()}return r}function D(t,n,i){let e=!1;for(let r=0,u=i.length-1;rn!=f>n&&t<(a-o)*(n-s)/(f-s+Number.EPSILON)+o&&(e=!e)}return e}function g(t){if(t.length===0)return 0;let n=t[0].x,i=t[0].x,e=t[0].y,r=t[0].y;for(const u of t)n=Math.min(n,u.x),i=Math.max(i,u.x),e=Math.min(e,u.y),r=Math.max(r,u.y);return(i-n)*(r-e)}function F(t,n,i,e){if(t.length===0)return t;const r=Math.max(16,Math.floor(n*i*5e-5)),u=t.filter(a=>g(a)>=r),o=u.length>0?u:t;if(e){const a=e.x+.5,f=e.y+.5,c=o.filter(x=>D(a,f,x));if(c.length>0)return c.sort((x,h)=>g(h)-g(x)),c}o.sort((a,f)=>g(f)-g(a));const s=g(o[0])*.05;return o.filter(a=>g(a)>=s)}function S(t,n,i,e,r){if(e<0||r<0||e>=n||r>=i||!t[r*n+e])return null;const u=new Uint8Array(n*i),o=[r*n+e];for(u[o[0]]=255;o.length>0;){const s=o.pop(),a=s%n,f=(s-a)/n;if(a>0){const c=s-1;t[c]&&!u[c]&&(u[c]=255,o.push(c))}if(a+10){const c=s-n;t[c]&&!u[c]&&(u[c]=255,o.push(c))}if(f+10;){const y=d.pop();c+=1;const l=y%n,b=(y-l)/n;if(x+=l,h+=b,l>0){const m=y-1;t[m]&&!e[m]&&(e[m]=1,d.push(m))}if(l+10){const m=y-n;t[m]&&!e[m]&&(e[m]=1,d.push(m))}if(b+1r&&(r=c,u={x:Math.floor(x/c),y:Math.floor(h/c)})}}return u}function w(t,n,i,e,r){let u=t;if(r){const f=S(t,n,i,r.x,r.y);if(!f)return p.Skia.Path.Make();u=f}const o=B(u,n,i),s=C(o),a=F(s,n,i,r);return v(a,n,i,e)}function T(t,n,i,e){return e?{x:Math.min(n-1,Math.max(0,Math.floor(e.x*n))),y:Math.min(i-1,Math.max(0,Math.floor(e.y*i)))}:N(t,n,i)??void 0}function X(t,n,i,e,r){const o=A(n,i).get(t);if(!o)return p.Skia.Path.Make();const{cols:s,rows:a}=i,f=T(o,s,a,r);return w(o,s,a,e,f)}function A(t,n){const{labels:i,baseboardBinary:e,cols:r,rows:u,wallSubLabels:o}=n,s=r*u,a=new Map,f=(0,R.getMaskSegmentRuntimeConfig)().mask.semanticColors,c=new Int32Array(f.length);c.fill(-1);const x=new Map;let h=null;for(const l of t){if(a.set(l.id,new Uint8Array(s)),l.thinStrip){h=l.id;continue}const b=/^wall-(\d+)$/.exec(l.name);if(b&&o){x.set(Number(b[1])-1,l.id);continue}const m=f.findIndex(M=>M.name===l.name);m>=0&&(c[m]=l.id)}const d=f.length,y=f.findIndex(l=>l.name==="wall");for(let l=0;l0){a.get(h)[l]=255;continue}if(o&&y>=0&&i[l]===y){const m=o[l];if(m!==255){const M=x.get(m);M!==void 0&&(a.get(M)[l]=255)}continue}const b=i[l];b=0&&(a.get(c[b])[l]=255)}return a}function Y(t,n,i){const{cols:e,rows:r}=n,u=A(t,n),o=new Map;for(const s of t){const a=u.get(s.id);if(!a){o.set(s.id,p.Skia.Path.Make());continue}o.set(s.id,w(a,e,r,i))}return o} diff --git a/dist/utils/maskSegmentRuntime.js b/dist/utils/maskSegmentRuntime.js index da39d73..9e10099 100644 --- a/dist/utils/maskSegmentRuntime.js +++ b/dist/utils/maskSegmentRuntime.js @@ -1 +1 @@ -"use strict";var l=Object.defineProperty;var f=Object.getOwnPropertyDescriptor;var b=Object.getOwnPropertyNames;var M=Object.prototype.hasOwnProperty;var k=(e,o)=>{for(var a in o)l(e,a,{get:o[a],enumerable:!0})},y=(e,o,a,p)=>{if(o&&typeof o=="object"||typeof o=="function")for(let s of b(o))!M.call(e,s)&&s!==a&&l(e,s,{get:()=>o[s],enumerable:!(p=f(o,s))||p.enumerable});return e};var h=e=>y(l({},"__esModule",{value:!0}),e);var K={};k(K,{DEFAULT_INTERACTION_CONFIG:()=>P,DEFAULT_MASK_CONFIG:()=>n,DEFAULT_PAINT_CONFIG:()=>C,DEFAULT_PIPELINE_CONFIG:()=>c,PIPELINE_HIGH:()=>x,PIPELINE_LOW:()=>S,PIPELINE_MEDIUM:()=>u,PIPELINE_PRESETS:()=>R,createRuntimeConfig:()=>d,getMaskRuntimeRevision:()=>L,getMaskSegmentRuntimeConfig:()=>N,mergeMaskConfig:()=>m,resetMaskSegmentRuntimeConfig:()=>W,resolvePipelineConfig:()=>I,setMaskSegmentRuntimeConfig:()=>E});module.exports=h(K);var t=require("./maskSemanticPalette");const x={maxImageLongSide:1440,paintFreqMaxLongSide:960,originPreviewMaxLongSide:720,maskPathMaxLongSide:960,minContourArea:50,contourApproxEpsilon:.002,maxRegions:800},u={maxImageLongSide:720,paintFreqMaxLongSide:480,originPreviewMaxLongSide:360,maskPathMaxLongSide:480,minContourArea:100,contourApproxEpsilon:.003,maxRegions:500},S={maxImageLongSide:360,paintFreqMaxLongSide:240,originPreviewMaxLongSide:180,maskPathMaxLongSide:240,minContourArea:200,contourApproxEpsilon:.005,maxRegions:300},R={high:x,medium:u,low:S},c=u;function I(e,o){return{...e!=null?R[e]:c,...o}}const A=[{b:138,g:126,r:110},{b:92,g:124,r:86},{b:70,g:80,r:158},{b:54,g:134,r:182},{b:128,g:98,r:142},{b:76,g:120,r:138}],C={palette:A,colorBaseOpacity:.88,lLightOpacity:.5,textureOpacity:.85,lLowBlurKernel:7,lLowContrast:1.1,lLowBrightness:.92,lHighGain:1.22,maskFeatherColor:1.6,maskFeatherTexture:.9,regionOverlayFill:"#FFC14D",regionOutlineStrokeWidth:4},P={kickMaskPickRadiusPx:36,pickMapSearchRadiusPx:14,thinStripPadding:.008,regionPadding:.003,initRegionFlashMs:1e3,enableInitRegionFlash:!0},n={semanticColors:t.MASK_SEMANTIC_COLORS,baseboardMaxColorDist:42,blackThreshold:30,quantStep:64,baseboardStripQuantKeys:new Set(t.BASEBOARD_STRIP_QUANT_KEYS),wallQuantKeys:new Set(t.WALL_QUANT_KEYS),cabinetQuantKeys:new Set(t.CABINET_QUANT_KEYS),maxRegionColors:6,secondarySemanticNames:new Set(["garageDoor","roof","eave"]),secondaryMinPixelRatio:.002,junctionHRadiusPx:24,junctionVRadiusPx:2,kickBridgeHalfWPx:6,baseboardJunctionRowMarginPx:1,baseboardJunctionVReachPx:2,baseboardMinRunPx:2,splitWalls:!1,splitWallsMaxCount:8,splitWallsMinAreaRatio:.002,splitWallsColorDistSq:1400,splitWallsChromaBlurRadius:5,splitWallsNeutralChromaMax:14};function r(e){return new Set(e??[])}function m(e){return e?{semanticColors:e.semanticColors??n.semanticColors,baseboardMaxColorDist:e.baseboardMaxColorDist??n.baseboardMaxColorDist,blackThreshold:e.blackThreshold??n.blackThreshold,quantStep:e.quantStep??n.quantStep,baseboardStripQuantKeys:e.baseboardStripQuantKeys?r(e.baseboardStripQuantKeys):new Set(n.baseboardStripQuantKeys),wallQuantKeys:e.wallQuantKeys?r(e.wallQuantKeys):new Set(n.wallQuantKeys),cabinetQuantKeys:e.cabinetQuantKeys?r(e.cabinetQuantKeys):new Set(n.cabinetQuantKeys),maxRegionColors:e.maxRegionColors??n.maxRegionColors,secondarySemanticNames:e.secondarySemanticNames?r(e.secondarySemanticNames):new Set(n.secondarySemanticNames),secondaryMinPixelRatio:e.secondaryMinPixelRatio??n.secondaryMinPixelRatio,junctionHRadiusPx:e.junctionHRadiusPx??n.junctionHRadiusPx,junctionVRadiusPx:e.junctionVRadiusPx??n.junctionVRadiusPx,kickBridgeHalfWPx:e.kickBridgeHalfWPx??n.kickBridgeHalfWPx,baseboardJunctionRowMarginPx:e.baseboardJunctionRowMarginPx??n.baseboardJunctionRowMarginPx,baseboardJunctionVReachPx:e.baseboardJunctionVReachPx??n.baseboardJunctionVReachPx,baseboardMinRunPx:e.baseboardMinRunPx??n.baseboardMinRunPx,splitWalls:e.splitWalls??n.splitWalls,splitWallsMaxCount:e.splitWallsMaxCount??n.splitWallsMaxCount,splitWallsMinAreaRatio:e.splitWallsMinAreaRatio??n.splitWallsMinAreaRatio,splitWallsColorDistSq:e.splitWallsColorDistSq??n.splitWallsColorDistSq,splitWallsChromaBlurRadius:e.splitWallsChromaBlurRadius??n.splitWallsChromaBlurRadius,splitWallsNeutralChromaMax:e.splitWallsNeutralChromaMax??n.splitWallsNeutralChromaMax}:{...n}}function d(e){return{pipeline:{...c,...e?.pipelineConfig},mask:m(e?.maskConfig),paint:{...C,...e?.paintConfig,palette:e?.paintConfig?.palette??C.palette},interaction:{...P,...e?.interactionConfig}}}let i=d(),g=0;function L(){return g}function E(e){return i={pipeline:e?.pipelineConfig?{...i.pipeline,...e.pipelineConfig}:i.pipeline,mask:e?.maskConfig?m(e.maskConfig):i.mask,paint:e?.paintConfig?{...i.paint,...e.paintConfig}:i.paint,interaction:e?.interactionConfig?{...i.interaction,...e.interactionConfig}:i.interaction},g+=1,i}function N(){return i}function W(){return i=d(),g+=1,i} +"use strict";var r=Object.defineProperty;var f=Object.getOwnPropertyDescriptor;var b=Object.getOwnPropertyNames;var M=Object.prototype.hasOwnProperty;var k=(e,a)=>{for(var t in a)r(e,t,{get:a[t],enumerable:!0})},W=(e,a,t,d)=>{if(a&&typeof a=="object"||typeof a=="function")for(let s of b(a))!M.call(e,s)&&s!==t&&r(e,s,{get:()=>a[s],enumerable:!(d=f(a,s))||d.enumerable});return e};var h=e=>W(r({},"__esModule",{value:!0}),e);var K={};k(K,{DEFAULT_INTERACTION_CONFIG:()=>P,DEFAULT_MASK_CONFIG:()=>n,DEFAULT_PAINT_CONFIG:()=>C,DEFAULT_PIPELINE_CONFIG:()=>c,PIPELINE_HIGH:()=>S,PIPELINE_LOW:()=>x,PIPELINE_MEDIUM:()=>u,PIPELINE_PRESETS:()=>R,createRuntimeConfig:()=>p,getMaskRuntimeRevision:()=>L,getMaskSegmentRuntimeConfig:()=>I,mergeMaskConfig:()=>m,resetMaskSegmentRuntimeConfig:()=>N,resolvePipelineConfig:()=>y,setMaskSegmentRuntimeConfig:()=>E});module.exports=h(K);var o=require("./maskSemanticPalette");const S={maxImageLongSide:1440,paintFreqMaxLongSide:960,originPreviewMaxLongSide:720,maskPathMaxLongSide:960,minContourArea:50,contourApproxEpsilon:.002,maxRegions:800},u={maxImageLongSide:720,paintFreqMaxLongSide:480,originPreviewMaxLongSide:360,maskPathMaxLongSide:480,minContourArea:100,contourApproxEpsilon:.003,maxRegions:500},x={maxImageLongSide:360,paintFreqMaxLongSide:240,originPreviewMaxLongSide:180,maskPathMaxLongSide:240,minContourArea:200,contourApproxEpsilon:.005,maxRegions:300},R={high:S,medium:u,low:x},c=u;function y(e,a){return{...e!=null?R[e]:c,...a}}const A=[{b:138,g:126,r:110},{b:92,g:124,r:86},{b:70,g:80,r:158},{b:54,g:134,r:182},{b:128,g:98,r:142},{b:76,g:120,r:138}],C={palette:A,colorBaseOpacity:.88,lLightOpacity:.5,textureOpacity:.85,lLowBlurKernel:7,lLowContrast:1.1,lLowBrightness:.92,lHighGain:1.22,maskFeatherColor:1.6,maskFeatherTexture:.9,regionOverlayFill:"#FFC14D",regionOutlineStrokeWidth:4},P={kickMaskPickRadiusPx:36,pickMapSearchRadiusPx:14,thinStripPadding:.008,regionPadding:.003,initRegionFlashMs:1e3,enableInitRegionFlash:!0},n={semanticColors:o.MASK_SEMANTIC_COLORS,baseboardMaxColorDist:42,blackThreshold:30,quantStep:64,baseboardStripQuantKeys:new Set(o.BASEBOARD_STRIP_QUANT_KEYS),wallQuantKeys:new Set(o.WALL_QUANT_KEYS),cabinetQuantKeys:new Set(o.CABINET_QUANT_KEYS),maxRegionColors:6,secondarySemanticNames:new Set(["garageDoor","roof","eave"]),secondaryMinPixelRatio:.002,junctionHRadiusPx:24,junctionVRadiusPx:2,kickBridgeHalfWPx:6,baseboardJunctionRowMarginPx:1,baseboardJunctionVReachPx:2,baseboardMinRunPx:2,splitWalls:!1,splitWallsMaxCount:8,splitWallsMinAreaRatio:.002,splitWallsColorDistSq:1400,splitWallsChromaBlurRadius:5,splitWallsNeutralChromaMax:14,splitWallsEdgeBarrierThreshold:160,splitWallsCloseMaskRadius:4,manualSplitWalls:!1,manualSplitWallsMaxCount:8,manualSplitWallsGapAbsorbDilatePx:5,magneticLasso:!1,activeContourRefine:!1};function l(e){return new Set(e??[])}function m(e){return e?{semanticColors:e.semanticColors??n.semanticColors,baseboardMaxColorDist:e.baseboardMaxColorDist??n.baseboardMaxColorDist,blackThreshold:e.blackThreshold??n.blackThreshold,quantStep:e.quantStep??n.quantStep,baseboardStripQuantKeys:e.baseboardStripQuantKeys?l(e.baseboardStripQuantKeys):new Set(n.baseboardStripQuantKeys),wallQuantKeys:e.wallQuantKeys?l(e.wallQuantKeys):new Set(n.wallQuantKeys),cabinetQuantKeys:e.cabinetQuantKeys?l(e.cabinetQuantKeys):new Set(n.cabinetQuantKeys),maxRegionColors:e.maxRegionColors??n.maxRegionColors,secondarySemanticNames:e.secondarySemanticNames?l(e.secondarySemanticNames):new Set(n.secondarySemanticNames),secondaryMinPixelRatio:e.secondaryMinPixelRatio??n.secondaryMinPixelRatio,junctionHRadiusPx:e.junctionHRadiusPx??n.junctionHRadiusPx,junctionVRadiusPx:e.junctionVRadiusPx??n.junctionVRadiusPx,kickBridgeHalfWPx:e.kickBridgeHalfWPx??n.kickBridgeHalfWPx,baseboardJunctionRowMarginPx:e.baseboardJunctionRowMarginPx??n.baseboardJunctionRowMarginPx,baseboardJunctionVReachPx:e.baseboardJunctionVReachPx??n.baseboardJunctionVReachPx,baseboardMinRunPx:e.baseboardMinRunPx??n.baseboardMinRunPx,splitWalls:e.splitWalls??n.splitWalls,splitWallsMaxCount:e.splitWallsMaxCount??n.splitWallsMaxCount,splitWallsMinAreaRatio:e.splitWallsMinAreaRatio??n.splitWallsMinAreaRatio,splitWallsColorDistSq:e.splitWallsColorDistSq??n.splitWallsColorDistSq,splitWallsChromaBlurRadius:e.splitWallsChromaBlurRadius??n.splitWallsChromaBlurRadius,splitWallsNeutralChromaMax:e.splitWallsNeutralChromaMax??n.splitWallsNeutralChromaMax,splitWallsEdgeBarrierThreshold:e.splitWallsEdgeBarrierThreshold??n.splitWallsEdgeBarrierThreshold,splitWallsCloseMaskRadius:e.splitWallsCloseMaskRadius??n.splitWallsCloseMaskRadius,manualSplitWalls:e.manualSplitWalls??n.manualSplitWalls,manualSplitWallsMaxCount:e.manualSplitWallsMaxCount??n.manualSplitWallsMaxCount,manualSplitWallsGapAbsorbDilatePx:e.manualSplitWallsGapAbsorbDilatePx??n.manualSplitWallsGapAbsorbDilatePx,magneticLasso:e.magneticLasso??n.magneticLasso,activeContourRefine:e.activeContourRefine??n.activeContourRefine}:{...n}}function p(e){return{pipeline:{...c,...e?.pipelineConfig},mask:m(e?.maskConfig),paint:{...C,...e?.paintConfig,palette:e?.paintConfig?.palette??C.palette},interaction:{...P,...e?.interactionConfig}}}let i=p(),g=0;function L(){return g}function E(e){return i={pipeline:e?.pipelineConfig?{...i.pipeline,...e.pipelineConfig}:i.pipeline,mask:e?.maskConfig?m(e.maskConfig):i.mask,paint:e?.paintConfig?{...i.paint,...e.paintConfig}:i.paint,interaction:e?.interactionConfig?{...i.interaction,...e.interactionConfig}:i.interaction},g+=1,i}function I(){return i}function N(){return i=p(),g+=1,i} diff --git a/dist/utils/maskSegmentation.d.ts b/dist/utils/maskSegmentation.d.ts index 5378fd0..2967292 100644 --- a/dist/utils/maskSegmentation.d.ts +++ b/dist/utils/maskSegmentation.d.ts @@ -85,6 +85,10 @@ export type RegionMaskData = { cols: number; rows: number; wallSubLabels?: Uint8Array; + /** Semantic index โ†’ name table captured at segmentation time (must match labels buffer). */ + indexToName?: string[]; + /** Wall semantic index in labels buffer (captured at segmentation time). */ + wallSemanticIdx?: number; }; /** downsample mask path building (screen display does not need segmentation resolution, click still uses full resolution pickMap) */ export declare function downsampleMaskDataForPaths(maskData: RegionMaskData, maxLongSide: number): RegionMaskData; diff --git a/dist/utils/maskSegmentation.js b/dist/utils/maskSegmentation.js index 3583c9a..1183abe 100644 --- a/dist/utils/maskSegmentation.js +++ b/dist/utils/maskSegmentation.js @@ -1 +1 @@ -"use strict";var Rn=Object.create;var v=Object.defineProperty;var Pn=Object.getOwnPropertyDescriptor;var Sn=Object.getOwnPropertyNames;var kn=Object.getPrototypeOf,Un=Object.prototype.hasOwnProperty;var wn=(t,n)=>{for(var e in n)v(t,e,{get:n[e],enumerable:!0})},on=(t,n,e,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of Sn(n))!Un.call(t,r)&&r!==e&&v(t,r,{get:()=>n[r],enumerable:!(o=Pn(n,r))||o.enumerable});return t};var Bn=(t,n,e)=>(e=t!=null?Rn(kn(t)):{},on(n||!t||!t.__esModule?v(e,"default",{value:t,enumerable:!0}):e,t)),Cn=t=>on(v({},"__esModule",{value:!0}),t);var mt={};wn(mt,{appendLabelMaskToPathBuilder:()=>Fn,appendMaskBinaryToPathBuilder:()=>On,buildAllRegionMaskPaths:()=>Wn,buildAllRegionOutlinePaths:()=>L.buildAllRegionOutlinePaths,buildBaseboardBinaryFromMask:()=>Kn,buildRegionOutlinePathForRegion:()=>L.buildRegionOutlinePathForRegion,buildRegionOutlinePolygons:()=>Tn,downsampleMaskDataForPaths:()=>Qn,extractRegionsFromMask:()=>st,extractRegionsFromMaskBuffer:()=>ut,extractRegionsFromMaskBufferSync:()=>Z,getMaskQuantKey:()=>zn,isBaseboardMaskPixel:()=>$n,isBaseboardPixel:()=>An.isStrictBaseboardPixel,isKickPlatePixel:()=>Gn,upscaleBinaryMask:()=>Jn});module.exports=Cn(mt);var k=Bn(require("./opencvAdapter")),Mn=require("@shopify/react-native-skia"),P=require("./maskSemanticPalette"),U=require("./maskSegmentRuntime"),L=require("./maskOutlinePaths"),An=require("./maskSemanticPalette");function p(){return(0,U.getMaskSegmentRuntimeConfig)().mask}const an=5,In=10;function un(t){return[{x:t.x,y:t.y},{x:t.x+t.w,y:t.y},{x:t.x+t.w,y:t.y+t.h},{x:t.x,y:t.y+t.h}]}function Tn(t){return t.outlinePolygons&&t.outlinePolygons.length>0?t.outlinePolygons:t.thinStrip||t.polygons.length<=In?t.polygons:[un(t.bbox)]}function Nn(t){return t.name===P.BASEBOARD_SEMANTIC_NAME}function En(t,n,e){const o=new Uint8Array(t),r=p().kickBridgeHalfWPx;for(let i=0;i=n||(o[i*n+s]=255)}return o}function H(t,n,e){return t.map(o=>[{x:o.minX/n,y:o.y/e},{x:(o.maxX+1)/n,y:o.y/e},{x:(o.maxX+1)/n,y:(o.y+1)/e},{x:o.minX/n,y:(o.y+1)/e}])}function q(t){const n=Math.min(255,Math.round(t/p().quantStep)*p().quantStep);return n>=192?n===255?4:3:n>=128?2:n>=64?1:0}function sn(t,n,e){return q(t)*25+q(n)*5+q(e)}function Ln(){const t=(0,U.getMaskRuntimeRevision)();return mn===t&&j||(j=new Set([...(0,P.getBaseboardStripQuantKeys)()].map(n=>{const[e,o,r]=n.split(",").map(i=>Number(i));return sn(e,o,r)})),mn=t),j}let mn=-1,j=null,ln=-1,O=null;function Yn(t){const n=new Uint8Array(256);for(let e=0;e<256;e++){const o=Math.min(255,Math.round(e/t)*t);o>=192?n[e]=o===255?4:3:o>=128?n[e]=2:o>=64?n[e]=1:n[e]=0}return n}function _n(){const t=(0,U.getMaskRuntimeRevision)();return ln===t&&O||(O=Yn(p().quantStep),ln=t),O}function V(t){return t>=4?255:t>=3?192:t>=2?128:t>=1?64:0}function Xn(t){const n=t/25|0,e=t%25/5|0,o=t%5;return[V(n),V(e),V(o)]}let cn=-1,K=null,J=null,bn=-1;function vn(){const t=(0,U.getMaskRuntimeRevision)();if(bn===t&&J)return J;const n=new Uint8Array(125);for(const e of Ln())n[e]=1;return J=n,bn=t,n}function jn(t,n,e){let o=n,r=e,i=-1,u=-1;for(let a=0;ai&&(i=m),au&&(u=a))}return i<0?null:{x:o/n,y:r/e,w:(i-o+1)/n,h:(u-r+1)/e}}function On(t,n,e,o,r,i=p().baseboardMinRunPx){for(let u=0;u0;if(y&&a<0&&(a=b),!y&&a>=0){if(b-a>=i){const f=a/n,g=b/n;r.moveTo(o.x+f*o.w,l),r.lineTo(o.x+g*o.w,l),r.lineTo(o.x+g*o.w,c),r.lineTo(o.x+f*o.w,c),r.close()}a=-1}}}}function Fn(t,n,e,o,r,i,u=p().baseboardMinRunPx){for(let a=0;a=0){if(f-s>=u){const M=s/e,A=f/e;i.moveTo(r.x+M*r.w,b),i.lineTo(r.x+A*r.w,b),i.lineTo(r.x+A*r.w,y),i.lineTo(r.x+M*r.w,y),i.close()}s=-1}}}}function fn(t,n,e,o,r,i,u,a){if(n-tM.name===f.name);g>=0&&(m[g]=f.id)}const c=s.length,b=p().baseboardMinRunPx;for(let f=0;f0;h&&g<0&&(g=x),!h&&g>=0&&(fn(g,x,f,i,u,e,a.get(l),b),g=-1)}let d=-1;if(x=0&&(d=h)}if(d!==A){if(A>=0&&M>=0){const h=m[A];fn(M,x,f,i,u,e,a.get(h),b)}A=d,M=d>=0?x:-1}}}const y=new Map;for(const[f,g]of a)y.set(f,g);return y}function $(t,n,e,o){const r=[];for(let i=0;i0;s&&u<0&&(u=a),!s&&u>=0&&(a-u>=o&&r.push({minX:u,maxX:a-1,y:i}),u=-1)}}return r}function Dn(t){if(t.length===0)return null;let n=1,e=1,o=0,r=0;for(const i of t)for(const u of i)n=Math.min(n,u.x),e=Math.min(e,u.y),o=Math.max(o,u.x),r=Math.max(r,u.y);return{x:n,y:e,w:o-n,h:r-e}}function bt(t,n,e){let o=0;for(let l=0;lr&&(r=i))}return r<0?null:{minY:o,maxY:r}}function dn(t,n,e,o,r){if(!r)return!1;const i=t%e,u=(t-i)/e;if(ur.maxY+p().baseboardJunctionRowMarginPx)return!1;const a=p().kickBridgeHalfWPx;for(let s=-p().baseboardJunctionVReachPx;s<=p().baseboardJunctionVReachPx;s++){const m=u+s;if(m<0||m>=o)continue;const l=m*e;for(let c=-a;c<=a;c++){const b=i+c;if(!(b<0||b>=e)&&n[l+b])return!0}}return!1}function Vn(t,n,e,o){const r=new Uint8Array(n*e),i=o??xn(t,n,e);for(let a=0;a=n||r>=e)return!1;if(i)return i[r*n+o]>0;const u=(r*n+o)*3,a=t[u],s=t[u+1],m=t[u+2];if(Q(a,s,m))return!1;if((0,P.isStrictBaseboardPixel)(a,s,m))return!0;const l=F(a,s,m);return(0,P.getBaseboardStripQuantKeys)().has(l)?xn(t,n,e)[r*n+o]>0:!1}function zn(t,n,e){return F(t,n,e)}function Gn(t,n,e){return(0,P.isStrictBaseboardPixel)(t,n,e)}function hn(t,n){const e=Math.min(t.x,n.x),o=Math.min(t.y,n.y),r=Math.max(t.x+t.w,n.x+n.w),i=Math.max(t.y+t.h,n.y+n.h);return{x:e,y:o,w:r-e,h:i-o}}function Q(t,n,e){const o=p().blackThreshold;return t[e.name,o])),pn=t,W}function Zn(){const t=(0,U.getMaskRuntimeRevision)();if(cn===t&&K)return K;const n=new Uint8Array(125);n.fill(E);const e=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors,o=G();for(const r of e){const i=o.get(r.name);if(i===void 0)continue;const{b:u,g:a,r:s}=r.bgr;n[sn(u,a,s)]=i}for(let r=0;r<125;r++){if(n[r]!==E)continue;const[i,u,a]=Xn(r),s=(0,P.classifyBgrPixelToSemantic)(i,u,a);n[r]=o.get(s)??E}return K=n,cn=t,n}function nt(t,n,e){const o=n*e,r=new Uint8Array(o);r.fill(E);const i=new Map,u=new Map,a=Zn(),s=G(),m=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors.map(R=>R.name),l=m.length,c=p().blackThreshold,b=_n(),y=vn(),f=new Int32Array(l),g=new Int32Array(l),M=new Int32Array(l),A=new Int32Array(l),S=new Uint8Array(l),x=new Int32Array(l),d=[],h=new Uint8Array(o);let C=0;const T=s.get(P.BASEBOARD_SEMANTIC_NAME);f.fill(n),g.fill(e),M.fill(-1),A.fill(-1);const N=t;for(let R=0;RM[w]&&(M[w]=I),RA[w]&&(A[w]=R))}}const B=1/n,Y=1/e;for(let R=0;R0&&(a[b]=c),s.set(m,(s.get(m)??0)+1);continue}const y=t[b];if(y===E)continue;const f=n[y];if(!f)continue;const g=e.get(f);g!==void 0&&(a[b]=g+1),s.set(f,(s.get(f)??0)+1)}return{pick:a,workAreas:s}}function ot(t,n,e){const o=n*e,r=new Uint8Array(o);r.set(t);for(let i=1;i=4){r[a]=c;break}}}return r}function it(t,n,e){const o=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors.map(r=>r.name);return o.map(r=>{if((t.get(r)??0)r!=null).sort((r,i)=>(t.get(i.name)??0)-(t.get(r.name)??0)).slice(0,p().maxRegionColors)}async function at(t,n,e,o,r){const i=await k.default.contourArea(t);if(i(b=Math.min(b,A.x),f=Math.max(f,A.x),y=Math.min(y,A.y),g=Math.max(g,A.y),{x:A.x/n,y:A.y/e})),area:i,bbox:{x:b/n,y:y/e,w:(f-b+1)/n,h:(g-y+1)/e}}}function dt(t,n,e,o){const r=new Uint8Array(n*e),i=[];let u=0,a=null;for(let s=0;s0;){const[x,d]=M.pop();if(g+=1,c=Math.min(c,x),b=Math.max(b,x),y=Math.min(y,d),f=Math.max(f,d),x>0){const h=d*n+(x-1);t[h]&&!r[h]&&(r[h]=1,M.push([x-1,d]))}if(x+10){const h=(d-1)*n+x;t[h]&&!r[h]&&(r[h]=1,M.push([x,d-1]))}if(d+1x.name),b=m.map(x=>{const{label:d,name:h,hex:C,color:T}=x,N=Nn(x);try{const B=N?jn(a,n,e):r.bboxes.get(h);if(!B)return __DEV__&&console.warn(`[MaskSegment] ${h} no valid contour, skipped`),null;const Y=[un(B)];return{id:d,name:h,hex:C,color:T,area:r.counts.get(h)??0,bbox:B,polygons:Y,outlinePolygons:Y,thinStrip:N}}catch(B){return __DEV__&&console.warn(`[MaskSegment] Color #${d} extraction failed:`,B instanceof Error?B.message:String(B)),null}}).filter(x=>x!=null);b.sort((x,d)=>d.area-x.area),b.forEach((x,d)=>{x.id=d});const y=b.slice(0,p().maxRegionColors),f=new Map(y.map(x=>[x.name,x.id])),g=__DEV__?performance.now():0,{pick:M,workAreas:A}=rt(r.labels,l,f,a,n,e),S=ot(M,n,e);for(const x of y){const d=A.get(x.name);d!=null&&(x.area=d)}return{regions:y,pickMap:{buffer:S,cols:n,rows:e},labels:r.labels,baseboardBinary:a,segCols:n,segRows:e}}async function st(t,n){const{buffer:e,cols:o,rows:r}=k.default.matToBuffer(t);return Z(e,o,r,n).regions} +"use strict";var Rn=Object.create;var v=Object.defineProperty;var Sn=Object.getOwnPropertyDescriptor;var Pn=Object.getOwnPropertyNames;var kn=Object.getPrototypeOf,Un=Object.prototype.hasOwnProperty;var wn=(t,n)=>{for(var e in n)v(t,e,{get:n[e],enumerable:!0})},on=(t,n,e,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of Pn(n))!Un.call(t,r)&&r!==e&&v(t,r,{get:()=>n[r],enumerable:!(o=Sn(n,r))||o.enumerable});return t};var Bn=(t,n,e)=>(e=t!=null?Rn(kn(t)):{},on(n||!t||!t.__esModule?v(e,"default",{value:t,enumerable:!0}):e,t)),In=t=>on(v({},"__esModule",{value:!0}),t);var mt={};wn(mt,{appendLabelMaskToPathBuilder:()=>Fn,appendMaskBinaryToPathBuilder:()=>On,buildAllRegionMaskPaths:()=>Wn,buildAllRegionOutlinePaths:()=>L.buildAllRegionOutlinePaths,buildBaseboardBinaryFromMask:()=>Kn,buildRegionOutlinePathForRegion:()=>L.buildRegionOutlinePathForRegion,buildRegionOutlinePolygons:()=>Tn,downsampleMaskDataForPaths:()=>Qn,extractRegionsFromMask:()=>st,extractRegionsFromMaskBuffer:()=>ut,extractRegionsFromMaskBufferSync:()=>Z,getMaskQuantKey:()=>zn,isBaseboardMaskPixel:()=>$n,isBaseboardPixel:()=>An.isStrictBaseboardPixel,isKickPlatePixel:()=>Gn,upscaleBinaryMask:()=>Jn});module.exports=In(mt);var P=Bn(require("./opencvAdapter")),Mn=require("@shopify/react-native-skia"),S=require("./maskSemanticPalette"),U=require("./maskSegmentRuntime"),L=require("./maskOutlinePaths"),An=require("./maskSemanticPalette");function M(){return(0,U.getMaskSegmentRuntimeConfig)().mask}const an=5,Cn=10;function un(t){return[{x:t.x,y:t.y},{x:t.x+t.w,y:t.y},{x:t.x+t.w,y:t.y+t.h},{x:t.x,y:t.y+t.h}]}function Tn(t){return t.outlinePolygons&&t.outlinePolygons.length>0?t.outlinePolygons:t.thinStrip||t.polygons.length<=Cn?t.polygons:[un(t.bbox)]}function Nn(t){return t.name===S.BASEBOARD_SEMANTIC_NAME}function En(t,n,e){const o=new Uint8Array(t),r=M().kickBridgeHalfWPx;for(let i=0;i=n||(o[i*n+s]=255)}return o}function H(t,n,e){return t.map(o=>[{x:o.minX/n,y:o.y/e},{x:(o.maxX+1)/n,y:o.y/e},{x:(o.maxX+1)/n,y:(o.y+1)/e},{x:o.minX/n,y:(o.y+1)/e}])}function q(t){const n=Math.min(255,Math.round(t/M().quantStep)*M().quantStep);return n>=192?n===255?4:3:n>=128?2:n>=64?1:0}function sn(t,n,e){return q(t)*25+q(n)*5+q(e)}function Ln(){const t=(0,U.getMaskRuntimeRevision)();return mn===t&&j||(j=new Set([...(0,S.getBaseboardStripQuantKeys)()].map(n=>{const[e,o,r]=n.split(",").map(i=>Number(i));return sn(e,o,r)})),mn=t),j}let mn=-1,j=null,ln=-1,O=null;function Yn(t){const n=new Uint8Array(256);for(let e=0;e<256;e++){const o=Math.min(255,Math.round(e/t)*t);o>=192?n[e]=o===255?4:3:o>=128?n[e]=2:o>=64?n[e]=1:n[e]=0}return n}function _n(){const t=(0,U.getMaskRuntimeRevision)();return ln===t&&O||(O=Yn(M().quantStep),ln=t),O}function V(t){return t>=4?255:t>=3?192:t>=2?128:t>=1?64:0}function Xn(t){const n=t/25|0,e=t%25/5|0,o=t%5;return[V(n),V(e),V(o)]}let cn=-1,K=null,J=null,bn=-1;function vn(){const t=(0,U.getMaskRuntimeRevision)();if(bn===t&&J)return J;const n=new Uint8Array(125);for(const e of Ln())n[e]=1;return J=n,bn=t,n}function jn(t,n,e){let o=n,r=e,i=-1,u=-1;for(let a=0;ai&&(i=m),au&&(u=a))}return i<0?null:{x:o/n,y:r/e,w:(i-o+1)/n,h:(u-r+1)/e}}function On(t,n,e,o,r,i=M().baseboardMinRunPx){for(let u=0;u0;if(y&&a<0&&(a=b),!y&&a>=0){if(b-a>=i){const f=a/n,g=b/n;r.moveTo(o.x+f*o.w,c),r.lineTo(o.x+g*o.w,c),r.lineTo(o.x+g*o.w,l),r.lineTo(o.x+f*o.w,l),r.close()}a=-1}}}}function Fn(t,n,e,o,r,i,u=M().baseboardMinRunPx){for(let a=0;a=0){if(f-s>=u){const p=s/e,A=f/e;i.moveTo(r.x+p*r.w,b),i.lineTo(r.x+A*r.w,b),i.lineTo(r.x+A*r.w,y),i.lineTo(r.x+p*r.w,y),i.close()}s=-1}}}}function fn(t,n,e,o,r,i,u,a){if(n-tp.name===f.name);g>=0&&(m[g]=f.id)}const l=s.length,b=M().baseboardMinRunPx;for(let f=0;f0;h&&g<0&&(g=x),!h&&g>=0&&(fn(g,x,f,i,u,e,a.get(c),b),g=-1)}let d=-1;if(x=0&&(d=h)}if(d!==A){if(A>=0&&p>=0){const h=m[A];fn(p,x,f,i,u,e,a.get(h),b)}A=d,p=d>=0?x:-1}}}const y=new Map;for(const[f,g]of a)y.set(f,g);return y}function $(t,n,e,o){const r=[];for(let i=0;i0;s&&u<0&&(u=a),!s&&u>=0&&(a-u>=o&&r.push({minX:u,maxX:a-1,y:i}),u=-1)}}return r}function Dn(t){if(t.length===0)return null;let n=1,e=1,o=0,r=0;for(const i of t)for(const u of i)n=Math.min(n,u.x),e=Math.min(e,u.y),o=Math.max(o,u.x),r=Math.max(r,u.y);return{x:n,y:e,w:o-n,h:r-e}}function bt(t,n,e){let o=0;for(let c=0;cr&&(r=i))}return r<0?null:{minY:o,maxY:r}}function dn(t,n,e,o,r){if(!r)return!1;const i=t%e,u=(t-i)/e;if(ur.maxY+M().baseboardJunctionRowMarginPx)return!1;const a=M().kickBridgeHalfWPx;for(let s=-M().baseboardJunctionVReachPx;s<=M().baseboardJunctionVReachPx;s++){const m=u+s;if(m<0||m>=o)continue;const c=m*e;for(let l=-a;l<=a;l++){const b=i+l;if(!(b<0||b>=e)&&n[c+b])return!0}}return!1}function Vn(t,n,e,o){const r=new Uint8Array(n*e),i=o??xn(t,n,e);for(let a=0;a=n||r>=e)return!1;if(i)return i[r*n+o]>0;const u=(r*n+o)*3,a=t[u],s=t[u+1],m=t[u+2];if(Q(a,s,m))return!1;if((0,S.isStrictBaseboardPixel)(a,s,m))return!0;const c=F(a,s,m);return(0,S.getBaseboardStripQuantKeys)().has(c)?xn(t,n,e)[r*n+o]>0:!1}function zn(t,n,e){return F(t,n,e)}function Gn(t,n,e){return(0,S.isStrictBaseboardPixel)(t,n,e)}function hn(t,n){const e=Math.min(t.x,n.x),o=Math.min(t.y,n.y),r=Math.max(t.x+t.w,n.x+n.w),i=Math.max(t.y+t.h,n.y+n.h);return{x:e,y:o,w:r-e,h:i-o}}function Q(t,n,e){const o=M().blackThreshold;return t[e.name,o])),pn=t,W}function Zn(){const t=(0,U.getMaskRuntimeRevision)();if(cn===t&&K)return K;const n=new Uint8Array(125);n.fill(E);const e=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors,o=G();for(const r of e){const i=o.get(r.name);if(i===void 0)continue;const{b:u,g:a,r:s}=r.bgr;n[sn(u,a,s)]=i}for(let r=0;r<125;r++){if(n[r]!==E)continue;const[i,u,a]=Xn(r),s=(0,S.classifyBgrPixelToSemantic)(i,u,a);n[r]=o.get(s)??E}return K=n,cn=t,n}function nt(t,n,e){const o=n*e,r=new Uint8Array(o);r.fill(E);const i=new Map,u=new Map,a=Zn(),s=G(),m=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors.map(R=>R.name),c=m.length,l=M().blackThreshold,b=_n(),y=vn(),f=new Int32Array(c),g=new Int32Array(c),p=new Int32Array(c),A=new Int32Array(c),k=new Uint8Array(c),x=new Int32Array(c),d=[],h=new Uint8Array(o);let B=0;const I=s.get(S.BASEBOARD_SEMANTIC_NAME);f.fill(n),g.fill(e),p.fill(-1),A.fill(-1);const N=t;for(let R=0;Rp[w]&&(p[w]=T),RA[w]&&(A[w]=R))}}const C=1/n,Y=1/e;for(let R=0;R0&&(a[b]=l),s.set(m,(s.get(m)??0)+1);continue}const y=t[b];if(y===E)continue;const f=n[y];if(!f)continue;const g=e.get(f);g!==void 0&&(a[b]=g+1),s.set(f,(s.get(f)??0)+1)}return{pick:a,workAreas:s}}function ot(t,n,e){const o=n*e,r=new Uint8Array(o);r.set(t);for(let i=1;i=4){r[a]=l;break}}}return r}function it(t,n,e){const o=(0,U.getMaskSegmentRuntimeConfig)().mask.semanticColors.map(r=>r.name);return o.map(r=>{if((t.get(r)??0)r!=null).sort((r,i)=>(t.get(i.name)??0)-(t.get(r.name)??0)).slice(0,M().maxRegionColors)}async function at(t,n,e,o,r){const i=await P.default.contourArea(t);if(i(b=Math.min(b,A.x),f=Math.max(f,A.x),y=Math.min(y,A.y),g=Math.max(g,A.y),{x:A.x/n,y:A.y/e})),area:i,bbox:{x:b/n,y:y/e,w:(f-b+1)/n,h:(g-y+1)/e}}}function dt(t,n,e,o){const r=new Uint8Array(n*e),i=[];let u=0,a=null;for(let s=0;s0;){const[x,d]=p.pop();if(g+=1,l=Math.min(l,x),b=Math.max(b,x),y=Math.min(y,d),f=Math.max(f,d),x>0){const h=d*n+(x-1);t[h]&&!r[h]&&(r[h]=1,p.push([x-1,d]))}if(x+10){const h=(d-1)*n+x;t[h]&&!r[h]&&(r[h]=1,p.push([x,d-1]))}if(d+1x.name),b=m.map(x=>{const{label:d,name:h,hex:B,color:I}=x,N=Nn(x);try{const C=N?jn(a,n,e):r.bboxes.get(h);if(!C)return __DEV__&&console.warn(`[MaskSegment] ${h} no valid contour, skipped`),null;const Y=[un(C)];return{id:d,name:h,hex:B,color:I,area:r.counts.get(h)??0,bbox:C,polygons:Y,outlinePolygons:Y,thinStrip:N}}catch(C){return __DEV__&&console.warn(`[MaskSegment] Color #${d} extraction failed:`,C instanceof Error?C.message:String(C)),null}}).filter(x=>x!=null);b.sort((x,d)=>d.area-x.area),b.forEach((x,d)=>{x.id=d});const y=b.slice(0,M().maxRegionColors),f=new Map(y.map(x=>[x.name,x.id])),g=__DEV__?performance.now():0,{pick:p,workAreas:A}=rt(r.labels,c,f,a,n,e),k=ot(p,n,e);for(const x of y){const d=A.get(x.name);d!=null&&(x.area=d)}return{regions:y,pickMap:{buffer:k,cols:n,rows:e},labels:r.labels,baseboardBinary:a,segCols:n,segRows:e}}async function st(t,n){const{buffer:e,cols:o,rows:r}=P.default.matToBuffer(t);return Z(e,o,r,n).regions} diff --git a/dist/utils/wallTextureSplit.d.ts b/dist/utils/wallTextureSplit.d.ts index f1c482b..5145e76 100644 --- a/dist/utils/wallTextureSplit.d.ts +++ b/dist/utils/wallTextureSplit.d.ts @@ -1,6 +1,24 @@ import type { SegmentMaskResult } from './maskSegmentation'; /** Placeholder value for non-wall pixels in wallSubLabels */ export declare const WALL_SUB_LABEL_NONE = 255; +export declare function buildPickMapAfterWallSplit(labels: Uint8Array, baseboardBinary: Uint8Array, wallIdx: number, wallSubLabels: Uint8Array, indexToName: string[], nameToId: Map, 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, 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 */ diff --git a/dist/utils/wallTextureSplit.js b/dist/utils/wallTextureSplit.js index 885cf2e..6a7ec5c 100644 --- a/dist/utils/wallTextureSplit.js +++ b/dist/utils/wallTextureSplit.js @@ -1 +1 @@ -"use strict";var j=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var X=Object.getOwnPropertyNames;var Y=Object.prototype.hasOwnProperty;var Z=(n,e)=>{for(var o in e)j(n,o,{get:e[o],enumerable:!0})},nn=(n,e,o,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of X(e))!Y.call(n,a)&&a!==o&&j(n,a,{get:()=>e[a],enumerable:!(r=V(e,a))||r.enumerable});return n};var en=n=>nn(j({},"__esModule",{value:!0}),n);var dn={};Z(dn,{WALL_SUB_LABEL_NONE:()=>D,isWallSubRegionName:()=>pn,splitWallRegionsByTexture:()=>yn});module.exports=en(dn);var J=require("./freqLayerPrep"),K=require("./maskSegmentRuntime"),Q=require("./maskSemanticPalette");const D=255;function T(){return(0,K.getMaskSegmentRuntimeConfig)().mask}function tn(n){return[{x:n.x,y:n.y},{x:n.x+n.w,y:n.y},{x:n.x+n.w,y:n.y+n.h},{x:n.x,y:n.y+n.h}]}function rn(n,e,o){const r=e*o,a=new Uint8Array(r),t=new Uint8Array(r);for(let i=0;ib||i<=a&&t>b)return!0;if(t>a&&i>a){const f=Math.atan2(e-128,n-128),c=Math.atan2(r-128,o-128);let m=Math.abs(f-c);if(m>Math.PI&&(m=2*Math.PI-m),m>Math.PI/4)return!0}return!1}function L(n,e,o,r,a,t){return an(n,e,o,r,t)?!1:on(n,e,o,r)<=a}function un(){return T().semanticColors.findIndex(e=>e.name==="wall")}function G(n,e,o,r){return e[r]?!1:n[r]===o}function cn(n,e,o,r,a,t,i,b,f){const c=t*i,m=new Int32Array(c);m.fill(-1);let y=0;const l=new Int32Array(c);for(let d=0;d=0)continue;const R=y;y+=1;const S=r[C],u=a[C];let g=S,p=u,A=1,w=0,M=0;for(l[M++]=C,m[C]=R;w=c)continue;const B=I%t;if(Math.abs(B-k)>1||!G(n,e,o,I)||m[I]>=0)continue;const N=r[I],P=a[I],q=L(r[U],a[U],N,P,b*1.8,f),z=L(O,_,N,P,b,f),s=L(S,u,N,P,b*3,f);!q||!z||!s||(m[I]=R,g+=N,p+=P,A+=1,l[M++]=I)}}}return{compLabels:m,compCount:y}}function H(n,e,o,r){const a=Array.from({length:e},(t,i)=>({label:i,area:0,bbox:{x:o,y:r,w:0,h:0}}));for(let t=0;tc.bbox.x+c.bbox.w&&(c.bbox.w=m-c.bbox.x),y>c.bbox.y+c.bbox.h&&(c.bbox.h=y-c.bbox.y)}return a}function mn(n,e,o,r,a,t){const i=new Float64Array(r),b=new Float64Array(r),f=new Float64Array(r),c=a*t;for(let l=0;l0?(m[l]=i[l]/f[l],y[l]=b[l]/f[l]):(m[l]=128,y[l]=128);return{meanA:m,meanB:y}}function sn(n,e,o,r,a,t,i,b,f){const c=e.length,{meanA:m,meanB:y}=mn(n,o,r,c,a,t),l=new Map,d=(u,g)=>{if(u===g||u<0||g<0)return;let p=l.get(u);p||(p=new Map,l.set(u,p)),p.set(g,(p.get(g)??0)+1)};for(let u=0;u=0&&d(A,w)}if(u+1=0&&d(A,w)}}}const x=new Int32Array(c);for(let u=0;u{for(;x[u]!==u;)x[u]=x[x[u]],u=x[u];return u},R=(u,g)=>{const p=C(u),A=C(g);if(p===A)return;const w=e[p].area,M=e[A].area;w>=M?(x[A]=p,e[p].area+=e[A].area,e[A].area=0):(x[p]=A,e[A].area+=e[p].area,e[p].area=0)};for(let u=0;u=i)continue;const g=l.get(u);if(!g||g.size===0)continue;let p=-1,A=0;for(const[w,M]of g)M>A&&(A=M,p=w);if(p>=0){if(!L(m[u],y[u],m[p],y[p],b,f))continue;R(u,p)}}const S=a*t;for(let u=0;u0&&(c[d]=l);continue}if(n[d]===o&&r[d]!==D){const S=`wall-${r[d]+1}`,u=t.get(S);u!==void 0&&(c[d]=u+1);continue}const x=n[d];if(x===255)continue;const C=a[x];if(!C)continue;const R=t.get(C);R!==void 0&&(c[d]=R+1)}return c}function bn(n,e,o){const r=e*o,a=new Uint8Array(r);a.set(n);for(let t=1;t=4){a[b]=y;break}}}return a}function yn(n,e,o,r,a){const t=T();if(!t.splitWalls)return n;const i=n.regions.find(s=>s.name==="wall");if(!i)return n;const b=un();if(b<0)return n;const{labels:f,baseboardBinary:c,regions:m}=n,y=o*r;if(e.length({...s,origIdx:h})).filter(s=>s.area>0).sort((s,h)=>h.area-s.area).slice(0,t.splitWallsMaxCount),U=new Map;M.forEach((s,h)=>{U.set(s.origIdx,h)});const k=new Uint8Array(y);k.fill(D);for(let s=0;ss.name!=="wall"),I=M.map((s,h)=>{const W=s.bbox,F=tn(W);return{id:0,name:`wall-${h+1}`,hex:O,color:{..._},polygons:[F],outlinePolygons:[F],bbox:W,area:s.area}}),B=[...$,...I];B.sort((s,h)=>h.area-s.area),B.forEach((s,h)=>{s.id=h});const N=new Map(B.map(s=>[s.name,s.id])),P=t.semanticColors.map(s=>s.name),q=ln(f,c,b,k,P,N,o,r),z=bn(q,o,r);for(const s of B){if(!/^wall-\d+$/.test(s.name))continue;const h=Number(s.name.slice(5))-1;let W=0;for(let F=0;F{for(var o in e)D(n,o,{get:e[o],enumerable:!0})},sn=(n,e,o,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let a of bn(e))!mn.call(n,a)&&a!==o&&D(n,a,{get:()=>e[a],enumerable:!(i=fn(e,a))||i.enumerable});return n};var yn=n=>sn(D({},"__esModule",{value:!0}),n);var On={};ln(On,{WALL_SUB_LABEL_NONE:()=>_,absorbSmallWallGapsForLassoPolygons:()=>Fn,buildPickMapAfterWallSplit:()=>tn,dilatePickBuffer1px:()=>rn,isWallSubRegionName:()=>Ln,patchPickMapForManualWallSplit:()=>Wn,splitWallRegionsByTexture:()=>En});module.exports=yn(On);var Z=require("./freqLayerPrep"),nn=require("./maskSegmentRuntime"),en=require("./maskSemanticPalette");const _=255;function H(){return(0,nn.getMaskSegmentRuntimeConfig)().mask}function dn(n,e,o){let i=-1,a=-1;for(let b=0;b=0)break}if(i<0)return[];const u=[[1,0],[1,-1],[0,-1],[-1,-1],[-1,0],[-1,1],[0,1],[1,1]],r=[];let l=i,f=a,c=7;for(let b=0;b=e||A<0||A>=o)&&n[A*e+s]){l=s,f=A,c=(d+4)%8,m=!0;break}}if(!m||r.length>2&&l===i&&f===a)break}return r}function xn(n,e){if(n.length<=2)return[...n];const o=new Uint8Array(n.length);o[0]=1,o[n.length-1]=1;const i=(a,u)=>{if(u-a<=1)return;const r=n[u].x-n[a].x,l=n[u].y-n[a].y,f=r*r+l*l;let c=0,b=a;for(let m=a+1;mc&&(c=t,b=m)}c>e&&(o[b]=1,i(a,b),i(b,u))};return i(0,n.length-1),n.filter((a,u)=>o[u])}const An=2.5;function gn(n,e,o){const i=e*o,a=new Uint8Array(i),u=new Uint8Array(i);for(let r=0;rI&&(I=q)}I>r&&(f[A]=1)}}return X(f,e,o)}function X(n,e,o){const i=new Uint8Array(n);for(let a=1;al||r<=a&&u>l)return!0;if(u>a&&r>a){const f=Math.atan2(e-128,n-128),c=Math.atan2(i-128,o-128);let b=Math.abs(f-c);if(b>Math.PI&&(b=2*Math.PI-b),b>Math.PI/4)return!0}return!1}function j(n,e,o,i,a,u){return Un(n,e,o,i,u)?!1:hn(n,e,o,i)<=a}function Mn(){return H().semanticColors.findIndex(e=>e.name==="wall")}function K(n,e,o,i){return e[i]?!1:n[i]===o}function Cn(n,e,o,i,a,u){const r=i*a,l=new Uint8Array(r);for(let t=0;t=0)continue;const k=t;t+=1;const y=i[h],p=a[h];let g=y,U=p,M=1,P=0,I=0;for(d[I++]=h,m[h]=k;P=b)continue;const G=w%r;if(Math.abs(G-E)>1||!K(n,e,o,w)||u[w]||m[w]>=0)continue;const N=i[w],W=a[w],v=j(i[B],a[B],N,W,f*1.8,c),q=j(F,L,N,W,f,c),O=j(y,p,N,W,f*3,c);!v||!q||!O||(m[w]=k,g+=N,U+=W,M+=1,d[I++]=w)}}}return{compLabels:m,compCount:t}}function Q(n,e,o,i){const a=Array.from({length:e},(u,r)=>({label:r,area:0,bbox:{x:o,y:i,w:0,h:0}}));for(let u=0;uc.bbox.x+c.bbox.w&&(c.bbox.w=b-c.bbox.x),m>c.bbox.y+c.bbox.h&&(c.bbox.h=m-c.bbox.y)}return a}function wn(n,e,o,i,a,u){const r=new Float64Array(i),l=new Float64Array(i),f=new Float64Array(i),c=a*u;for(let t=0;t0?(b[t]=r[t]/f[t],m[t]=l[t]/f[t]):(b[t]=128,m[t]=128);return{meanA:b,meanB:m}}function Pn(n,e,o,i,a,u,r,l,f){const c=e.length,{meanA:b,meanB:m}=wn(n,o,i,c,a,u),t=new Map,d=(y,p)=>{if(y===p||y<0||p<0)return;let g=t.get(y);g||(g=new Map,t.set(y,g)),g.set(p,(g.get(p)??0)+1)};for(let y=0;y=0&&d(U,M)}if(y+1=0&&d(U,M)}}}const s=new Int32Array(c);for(let y=0;y{for(;s[y]!==y;)s[y]=s[s[y]],y=s[y];return y},h=(y,p)=>{const g=A(y),U=A(p);if(g===U)return;const M=e[g].area,P=e[U].area;M>=P?(s[U]=g,e[g].area+=e[U].area,e[U].area=0):(s[g]=U,e[U].area+=e[g].area,e[g].area=0)};for(let y=0;y=r)continue;const p=t.get(y);if(!p||p.size===0)continue;let g=-1,U=0;for(const[M,P]of p)P>U&&(U=P,g=M);if(g>=0){if(!j(b[y],m[y],b[g],m[g],l,f))continue;h(y,g)}}for(let y=0;yU&&(U=P,g=M);g<0||e[y].area>=e[g].area||h(y,g)}const k=a*u;for(let y=0;y{if(t===d)return;let s=r.get(t);s||(s=new Map,r.set(t,s)),s.set(d,(s.get(d)??0)+1)};for(let t=0;t=0&&l(A,h)}if(t+1=0&&l(A,h)}}}const f=new Int32Array(u);for(let t=0;t{for(;f[t]!==t;)f[t]=f[f[t]],t=f[t];return t},b=(t,d)=>{const s=c(t),A=c(d);s!==A&&(e[s].area>=e[A].area?(f[A]=s,e[s].area+=e[A].area,e[A].area=0):(f[s]=A,e[A].area+=e[s].area,e[s].area=0))};for(let t=0;t=a)continue;const A=r.get(s);if(!A||A.size===0)continue;let h=-1,k=0;for(const[y,p]of A)f[y]===y&&p>k&&(k=p,h=y);h<0||(b(s,h),d=!0)}if(!d)break}const m=o*i;for(let t=0;t=0&&(n[t]=c(d))}}function V(n,e,o){const i=e*o,a=new Map,u=new Int32Array(i);u.fill(-1);for(let f=0;f0&&(c[d]=t);continue}if(o>=0&&n[d]===o){if(i[d]!==_){const k=`wall-${i[d]+1}`,y=u.get(k);y!==void 0&&(c[d]=y+1)}continue}const s=n[d];if(s===255)continue;const A=a[s];if(!A)continue;const h=u.get(A);h!==void 0&&(c[d]=h+1)}return c}function Wn(n,e,o,i,a,u,r,l){const f=r*l,c=new Uint8Array(n);if(i<0)return c;for(let b=0;b=4){a[l]=m;break}}}return a}const In=[[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]];function Rn(n,e,o){if(n.w===0&&n.h===0){n.x=e,n.y=o,n.w=1,n.h=1;return}const i=n.x+n.w,a=n.y+n.h;ei&&(n.w=e+1-n.x),oa&&(n.h=o+1-n.y)}function Nn(n,e,o,i,a,u){a.fill(0);for(let r=0;r=e||(a[c]++,Rn(u[c],l,r))}}function Fn(n,e,o,i,a,u,r,l,f,c,b){if(e<=0||b<=0||r<0)return;const m=t=>a[t]!==r||u[t]||l[t]!==_?!1:n[t]===_;for(let t=0;t=f||U<0||U>=c)continue;const M=U*f+g;m(M)&&s.push(M)}}if(s.length===0)break;for(const A of s)n[A]=t}Nn(n,e,f,c,o,i)}function En(n,e,o,i,a){const u=H();if(!u.splitWalls)return n;const r=n.regions.find(x=>x.name==="wall");if(!r)return n;const l=Mn();if(l<0)return n;const{labels:f,baseboardBinary:c,regions:b}=n,m=o*i;if(e.length0?Cn(f,c,l,o,i,t):{labels:f,baseboardBinary:c},s=d.labels,A=d.baseboardBinary,{aMap:h,bMap:k}=gn(e,o,i),y=pn(e,o,i,l,s,A,u.splitWallsEdgeBarrierThreshold??36),p=u.splitWallsColorDistSq,g=u.splitWallsNeutralChromaMax,U=Math.max(a,Math.floor(o*i*u.splitWallsMinAreaRatio)),{compLabels:M,compCount:P}=kn(s,A,l,h,k,y,o,i,p,g);if(P===0)return n;let I=Q(M,P,o,i);Pn(M,I,h,k,o,i,U,p,g);let B,E,z;{const x=V(M,o,i);Sn(x.labels,x.stats,o,i,U);const C=V(x.labels,o,i);B=C.labels,E=C.compCount,z=C.stats}if(E===0)return n;const F=z.map((x,C)=>({...x,origIdx:C})).filter(x=>x.area>0).sort((x,C)=>C.area-x.area).slice(0,u.splitWallsMaxCount),L=new Map;F.forEach((x,C)=>{L.set(x.origIdx,C)});const R=new Uint8Array(m);R.fill(_);for(let x=0;xx.name!=="wall"),q=F.map((x,C)=>{const S=W[C],$=S?dn(S,o,i):[],T=xn($,An),Y=T.length>=3?T:[{x:x.bbox.x,y:x.bbox.y},{x:x.bbox.x+x.bbox.w,y:x.bbox.y},{x:x.bbox.x+x.bbox.w,y:x.bbox.y+x.bbox.h},{x:x.bbox.x,y:x.bbox.y+x.bbox.h}];return{id:0,name:`wall-${C+1}`,hex:G,color:{...N},polygons:[Y],outlinePolygons:[Y],bbox:x.bbox,area:x.area}}),O=[...v,...q];O.sort((x,C)=>C.area-x.area),O.forEach((x,C)=>{x.id=C});const on=new Map(O.map(x=>[x.name,x.id])),an=u.semanticColors.map(x=>x.name),un=tn(f,c,l,R,an,on,o,i),cn=rn(un,o,i);for(const x of O){if(!/^wall-\d+$/.test(x.name))continue;const C=Number(x.name.slice(5))-1;let S=0;for(let $=0;$ Promise` | 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(null); +// Paint operations ref.current?.reset(); ref.current?.swap(); // toggle ref.current?.swap(true); // force show origin @@ -47,6 +60,15 @@ await ref.current?.resegment(); const regions = ref.current?.getRegions(); const painted = ref.current?.getPaintedRegions(); + +// Lasso operations +ref.current?.startLasso(); // enter lasso mode +// ... user taps wall areas to place vertices ... +const partitions = ref.current?.endLasso(); // convert polygons to wall-N regions +ref.current?.cancelLasso(); // discard in-progress lasso + +const manualRegions = ref.current?.getManualRegions(); +ref.current?.deleteLasso('lasso_1'); // remove a specific polygon ``` > `save` depends on the working buffer and pickMap being ready (typically after `interactive`); throws `'Image not ready, cannot save'` if not ready. diff --git a/docs/docs/interaction-guide.md b/docs/docs/interaction-guide.md index 0ed9065..bf8521b 100644 --- a/docs/docs/interaction-guide.md +++ b/docs/docs/interaction-guide.md @@ -5,9 +5,33 @@ title: Interaction Guide # ๐ŸŽฎ Interaction Guide +## Paint Mode + 1. ๐Ÿ” **Initial Carousel**: After regions are ready, each region's dashed outline flashes sequentially per `initRegionFlashMs` (default 1s); stops on first user touch. 2. ๐Ÿ” **Preview (no brush selected)**: Long-press a region to show dashed outline for the connected component under the touch point; tapping a black area shows no outline. 3. ๐ŸŽจ **Paint (brush selected)**: Tap a color in the bottom color bar or call `ref.setPaintColor` (or preselect via `initialPaintColor`), then tap a region to paint; tapping the same region again overwrites the color. 4. ๐Ÿ’ฌ **Tap without brush**: No paint is performed; `onPaintCallback` fires with `kind: 'brush_required'`, carrying a `hint` and target region info for the host to show a toast/modal prompting color selection. 5. โ†ฉ๏ธ **Undo**: Bottom-left button or `ref.reset()`; steps backward through paint history one action at a time. 6. ๐Ÿ‘๏ธ **Compare with Origin**: Bottom-right button or `ref.swap()`; hides the paint layer to show the original image. + +## Lasso Mode (Manual Wall Split) + +When `manualSplitWalls` is enabled and lasso mode is active: + +7. ๐Ÿงฒ **Enter Lasso**: Call `ref.startLasso()` to activate lasso mode. The lasso polygon overlay (orange) appears. +8. ๐Ÿ‘† **Place Vertices**: Tap on wall areas to place polygon vertices. Vertices snap to wall-mask edges/corners automatically. +9. ๐Ÿงฒ **Magnetic Lasso** (when `magneticLasso: true`): Paths between taps automatically follow strong image edges (green path overlay) via Sobel gradient + Dijkstra shortest-path. +10. ๐Ÿ”’ **Close Polygon**: Tap near the first vertex to close the polygon. A closed polygon is outlined in orange. +11. โœ‹ **Drag Vertices**: Touch and drag an existing vertex to reposition it. The vertex snaps to wall boundary/corner points, or stays within the wall mask for interior positions. +12. โœ… **End Lasso**: Call `ref.endLasso()` to convert all closed polygons into `wall-N` sub-regions ready for painting. +13. ๐Ÿ—‘๏ธ **Cancel Lasso**: Call `ref.cancelLasso()` to discard all in-progress lasso polygons without saving. +14. ๐Ÿ—‘๏ธ **Delete Lasso**: Call `ref.deleteLasso(id)` to remove a previously committed lasso polygon and its associated `wall-N` region. + +### Active Contour Refinement + +When `activeContourRefine: true`, the closed lasso polygon is automatically refined after `endLasso()`: + +- Each vertex samples positions along its outward normal direction +- Vertices expand to the nearest wall-mask boundary edge (balloon force) +- Douglas-Peucker simplification removes redundant vertices +- Result: the polygon hugs the true wall outline rather than the raw tap positions diff --git a/docs/docs/intro.md b/docs/docs/intro.md index ff1a8b5..f851800 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -14,6 +14,7 @@ A React Native **0.79** interactive mask segmentation library. The core export i - ๐Ÿง  **OpenCV** (`react-native-fast-opencv`): mask semantic layout, baseboard patching, region extraction - ๐Ÿ–Œ๏ธ **Skia RuntimeEffect (SkSL)**: single-pass full-screen shader blending original image + LAB low/high frequency texture color overlays - โœ‚๏ธ **Skia Path**: dashed outline highlights for regions +- ๐Ÿงฒ **Magnetic Lasso**: manual wall partitioning with edge-snapping (Sobel gradient + Dijkstra shortest-path) and Active Contour boundary refinement - ๐Ÿ‘† **Interaction**: bottom color bar for brush selection (optional initialization) โ†’ tap a region to paint; tapping without a brush selected fires `onPaintCallback` with a hint; long-press without a brush previews the region's dashed outline This repository serves as both the **library source** (`src/index.ts`) and a **self-test demo** (root `App.tsx`). @@ -30,8 +31,9 @@ This repository serves as both the **library source** (`src/index.ts`) and a **s 2. ๐Ÿงฉ **Segment** the mask via OpenCV into semantic regions (walls, ceiling, baseboard, etc.) 3. ๐ŸŽจ **Prepare** LAB frequency-layer textures via SkSL for realistic color blending 4. ๐Ÿ“ **Build** Skia dashed-outline paths for each region -5. ๐Ÿ‘† **Interactive** โ€” users select a brush color and tap regions to paint; paint layers preserve the underlying texture -6. ๐Ÿ’พ **Save** the composited result as PNG; export a JSON session for draft recovery +5. ๐Ÿงฒ **Manual Split** (optional) โ€” draw lasso polygons on walls to subdivide into independently-paintable `wall-N` regions, with optional edge-snapping and Active Contour refinement +6. ๐Ÿ‘† **Interactive** โ€” users select a brush color and tap regions to paint; paint layers preserve the underlying texture +7. ๐Ÿ’พ **Save** the composited result as PNG; export a JSON session for draft recovery The component emits `onWatch` state transitions through the pipeline so the host app can show appropriate loading states. diff --git a/docs/docs/project-structure.md b/docs/docs/project-structure.md index c30d3c4..88a24e8 100644 --- a/docs/docs/project-structure.md +++ b/docs/docs/project-structure.md @@ -17,6 +17,9 @@ MaskSegmentApp/ # Repo root (npm package react-nati โ”‚ โ”œโ”€โ”€ maskSegmentation.ts โ”‚ โ”œโ”€โ”€ maskSegmentRuntime.ts โ”‚ โ”œโ”€โ”€ maskSemanticPalette.ts +โ”‚ โ”œโ”€โ”€ magneticLasso.ts # Edge-snapping lasso (Sobel + Dijkstra) +โ”‚ โ”œโ”€โ”€ activeContour.ts # Active Contour refinement (snake + balloon) +โ”‚ โ”œโ”€โ”€ wallTextureSplit.ts # Automatic & manual wall texture splitting โ”‚ โ””โ”€โ”€ ... โ”œโ”€โ”€ example/ # โ˜… Recommended: consumer-side integration demo โ”‚ โ”œโ”€โ”€ App.tsx # Full example using only the public API diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/index.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/index.md index 81db280..18269ca 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/index.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/index.md @@ -23,6 +23,8 @@ import MaskSegmentCanvas, { type PaintConfig, type InteractionConfig, type SavePaintResult, + type LassoPolygon, + type ManualWallPartition, MASK_SEMANTIC_COLORS, BASEBOARD_SEMANTIC_NAME, prewarmPngBgrCacheAsync, @@ -38,6 +40,7 @@ import MaskSegmentCanvas, { | ็ป„ไปถ | `MaskSegmentCanvas`๏ผˆ้ป˜่ฎคๅฏผๅ‡บ๏ผ‰ | | Ref / Props ็ฑปๅž‹ | `MaskSegmentCanvasRef`, `MaskSegmentCanvasProps` | | ไผš่ฏ / ๅ›ž่ฐƒ็ฑปๅž‹ | `MaskSegmentSession`, `PaintCallbackPayload`, `PaintedRegionRecord`, `SavePaintResult` | +| ๅฅ—็ดข็ฑปๅž‹ | `LassoPolygon`, `ManualWallPartition` | | Watch ็ฑปๅž‹ | `MaskSegmentWatchState`, `MaskSegmentWatchDetail` | | ้…็ฝฎ็ฑปๅž‹ | `PipelineConfig`, `MaskSegmentConfig`, `PaintConfig`, `InteractionConfig` | | ่ฏญไน‰้ขœ่‰ฒ | `MASK_SEMANTIC_COLORS`, `BASEBOARD_SEMANTIC_NAME` | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/mask-config.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/mask-config.md index 516ef64..72224d7 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/mask-config.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/mask-config.md @@ -29,5 +29,23 @@ title: "Props๏ผšmaskConfig" | `splitWallsColorDistSq` | `number` | `1400` | ่ฟž้€šๅˆ†้‡่‰ฒๅบฆๅ‡ๅ€ผ่ท็ฆปๅนณๆ–น้˜ˆๅ€ผ | | `splitWallsChromaBlurRadius` | `number` | `5` | ไฟ็•™๏ผš่‰ฒๅบฆๅนณๆป‘ๅŠๅพ„ | | `splitWallsNeutralChromaMax` | `number` | `14` | ็™ฝ/็ฐๅข™้ขไฝŽ่‰ฒๅบฆๅŠๅพ„๏ผ›ไธŽๅฝฉ่‰ฒๅข™้ข็š„ๅผบๅˆถ่พน็•Œ | +| `splitWallsEdgeBarrierThreshold` | `number` | `160` | ้€้€š้“ BGR Sobel ๆขฏๅบฆ่พน็ผ˜ๅฑ้šœ้˜ˆๅ€ผ๏ผˆ0 = ็ฆ็”จ๏ผ‰ใ€‚ๅฏ่งๅข™้ขๆŽฅ็ผ โ‰ˆ 120โ€“280๏ผŒ็ป†ๅพฎๅ…‰็…งๆธๅ˜ โ‰ˆ 20โ€“80 | +| `splitWallsCloseMaskRadius` | `number` | `3` | ็ป„ไปถๆ ‡ๆณจๅ‰ๅข™้ข้ฎ็ฝฉๅญ”ๆดž๏ผˆ็ช—ๆˆทใ€้—จ๏ผ‰็š„ๅฝขๆ€ๅญฆ้—ญ่ฟ็ฎ—ๅŠๅพ„ใ€‚่ฎพไธบ 0 ็ฆ็”จ | +| `manualSplitWalls` | `boolean` | `false` | ไธบ `true` ๆ—ถ็ฆ็”จ่‡ชๅŠจ็บน็†ๅข™้ขๅˆ†ๅ‰ฒ๏ผŒๆ”นไธบๆ‰‹ๅŠจๅฅ—็ดขๅˆ†ๅŒบ | +| `manualSplitWallsMaxCount` | `number` | `8` | ๅฅ—็ดขๅฎšไน‰็š„ๆœ€ๅคงๆ‰‹ๅŠจๅข™้ขๅญๅŒบๅŸŸๆ•ฐ | +| `manualSplitWallsGapAbsorbDilatePx` | `number` | `5` | ๅฝขๆ€ๅญฆ่†จ่ƒ€ๅŠๅพ„๏ผˆๅˆ†ๅ‰ฒๅƒ็ด ๏ผ‰๏ผŒ็”จไบŽๅˆๅนถ็ป˜ๅˆถๅคš่พนๅฝขๅ‘จๅ›ด็š„ๆœชๅˆ†้…ๅข™้ข่–„็ผ | +| `magneticLasso` | `boolean` | `false` | ไธบ `true` ๆ—ถ๏ผŒๅฅ—็ดขๆจกๅผไฝฟ็”จ Sobel ๆขฏๅบฆ + Dijkstra ๆœ€็Ÿญ่ทฏๅพ„่ฟ›่กŒ่พน็ผ˜ๅธ้™„ | +| `activeContourRefine` | `boolean` | `false` | ็ป“ๆŸๅฅ—็ดขๅŽ๏ผŒๅฏนๆฏไธชๅคš่พนๅฝข่ฟ่กŒไธปๅŠจ่ฝฎๅป“็ฒพ็‚ผ๏ผŒๅฐ†้กถ็‚นๅ‘ๅค–ๆ‰ฉๅฑ•ๅˆฐๅข™้ข้ฎ็ฝฉ่พน็ผ˜ | ๅฏ็”จ `splitWalls` ๅŽ๏ผŒๅ•ไธช `wall` ๅŒบๅŸŸๅฐ†่ขซๆ›ฟๆขไธบๅคšไธช `wall-N` ๅญๅŒบๅŸŸ๏ผŒๆฏไธชๅญๅŒบๅŸŸๅฏ็‹ฌ็ซ‹ไธŠ่‰ฒๅ’Œๆ’ค้”€ใ€‚ๆ—งไผš่ฏไธญ `regionName: 'wall'` ็š„่ฎฐๅฝ•ๆ— ๆณ•ๆ˜ ๅฐ„ๅˆฐๆ–ฐ็š„ๅญๅŒบๅŸŸๅ็งฐ๏ผŒ้œ€้‡ๆ–ฐไธŠ่‰ฒใ€‚ + +### ๆ‰‹ๅŠจๅข™้ขๅˆ†ๅ‰ฒ๏ผˆๅฅ—็ดขๆจกๅผ๏ผ‰ + +ๅฝ“ `manualSplitWalls` ๅฏ็”จๆ—ถ๏ผŒ่‡ชๅŠจ็บน็†ๅข™้ขๅˆ†ๅ‰ฒ่ขซ็ฆ็”จใ€‚็”จๆˆทๅฟ…้กปไฝฟ็”จ **ๅฅ—็ดข๏ผˆLasso๏ผ‰** ๅŠŸ่ƒฝๅœจๅข™้ขไธŠๆ‰‹ๅŠจ็ป˜ๅˆถๅคš่พนๅฝข๏ผš + +- ่ฐƒ็”จ `ref.startLasso()` ่ฟ›ๅ…ฅๅฅ—็ดขๆจกๅผ๏ผŒ็„ถๅŽๅœจๅข™้ขๅŒบๅŸŸ็‚นๅ‡ปๆ”พ็ฝฎๅคš่พนๅฝข้กถ็‚นใ€‚ +- ๅฏ็”จ `magneticLasso` ่ฟ›่กŒ่พน็ผ˜ๅธ้™„ โ€” ่ทฏๅพ„ๅฐ†ๆฒฟๅ›พๅƒๅผบ่พน็ผ˜่ตฐ๏ผˆSobel ๆขฏๅบฆ + Dijkstra ๆœ€็Ÿญ่ทฏๅพ„๏ผ‰ใ€‚ +- ๅฏ็”จ `activeContourRefine` ๅœจๅฅ—็ดขๅฎŒๆˆๅŽ่‡ชๅŠจๅฐ†้กถ็‚นๅ‘ๅค–ๆ‰ฉๅฑ•ๅˆฐๅข™้ข้ฎ็ฝฉ่พน็•Œใ€‚ +- ่ฐƒ็”จ `ref.endLasso()` ๅฐ†้—ญๅˆๅฅ—็ดขๅคš่พนๅฝข่ฝฌๆขไธบๅฏไธŠ่‰ฒ็š„ `wall-N` ๅญๅŒบๅŸŸใ€‚ +- ่‡ชๅŠจๅˆ†ๅ‰ฒๆ—ถไฝฟ็”จ `splitWallsCloseMaskRadius` ๅกซๅ……ๅข™้ข้ฎ็ฝฉๅญ”ๆดž๏ผˆ็ช—ๆˆทใ€้—จ๏ผ‰ใ€‚ +- ่‡ชๅŠจๅˆ†ๅ‰ฒๆ—ถไฝฟ็”จ `splitWallsEdgeBarrierThreshold` ้˜ปๆญข BFS ่ทจ่ถŠๅผบ่พน็ผ˜๏ผˆ็ช—ๆก†ใ€้—จๆก†๏ผ‰ใ€‚ diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/ref-methods.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/ref-methods.md index 7b88f05..3c97e98 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/ref-methods.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/api/ref-methods.md @@ -20,16 +20,29 @@ title: "Ref ๆ–นๆณ•" | `resegment` | `() => Promise` | ๆธ…้™ค 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(null); +// ไธŠ่‰ฒๆ“ไฝœ ref.current?.reset(); ref.current?.swap(); // ๅˆ‡ๆข ref.current?.swap(true); // ๅผบๅˆถๆ˜พ็คบๅŽŸๅง‹ๅ›พๅƒ @@ -47,6 +60,15 @@ await ref.current?.resegment(); const regions = ref.current?.getRegions(); const painted = ref.current?.getPaintedRegions(); + +// ๅฅ—็ดขๆ“ไฝœ +ref.current?.startLasso(); // ่ฟ›ๅ…ฅๅฅ—็ดขๆจกๅผ +// ... ็”จๆˆทๅœจๅข™้ขๅŒบๅŸŸ็‚นๅ‡ปๆ”พ็ฝฎ้กถ็‚น ... +const partitions = ref.current?.endLasso(); // ๅฐ†ๅคš่พนๅฝข่ฝฌๆขไธบ wall-N ๅŒบๅŸŸ +ref.current?.cancelLasso(); // ไธขๅผƒ่ฟ›่กŒไธญ็š„ๅฅ—็ดข + +const manualRegions = ref.current?.getManualRegions(); +ref.current?.deleteLasso('lasso_1'); // ๅˆ ้™คๆŒ‡ๅฎšๅคš่พนๅฝข ``` > `save` ไพ่ต–ไบŽๅทฅไฝœ็ผ“ๅ†ฒๅŒบๅ’Œ pickMap ๅฐฑ็ปช๏ผˆ้€šๅธธๅœจ `interactive` ไน‹ๅŽ๏ผ‰๏ผ›ๅฆ‚ๆžœๆœชๅฐฑ็ปชๅˆ™ๆŠ›ๅ‡บ `'Image not ready, cannot save'`ใ€‚ diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/interaction-guide.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/interaction-guide.md index b48f56f..8ffc885 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/interaction-guide.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/interaction-guide.md @@ -5,9 +5,33 @@ title: ไบคไบ’ๆŒ‡ๅ— # ๐ŸŽฎ ไบคไบ’ๆŒ‡ๅ— +## ไธŠ่‰ฒๆจกๅผ + 1. ๐Ÿ” **ๅˆๅง‹่ฝฎๆ’ญ**๏ผšๅŒบๅŸŸๅฐฑ็ปชๅŽ๏ผŒๆฏไธชๅŒบๅŸŸ็š„่™š็บฟ่ฝฎๅป“ๆŒ‰ `initRegionFlashMs`๏ผˆ้ป˜่ฎค 1s๏ผ‰ไพๆฌก้—ช็ƒ๏ผ›้ฆ–ๆฌก็”จๆˆท่งฆๆ‘ธๆ—ถๅœๆญขใ€‚ 2. ๐Ÿ” **้ข„่งˆ๏ผˆๆœช้€‰ๆ‹ฉ็”ป็ฌ”๏ผ‰**๏ผš้•ฟๆŒ‰ๅŒบๅŸŸๅฏๆ˜พ็คบ่งฆๆ‘ธ็‚นไธ‹่ฟž้€šๅˆ†้‡็š„่™š็บฟ่ฝฎๅป“๏ผ›็‚นๅ‡ป้ป‘่‰ฒๅŒบๅŸŸไธๆ˜พ็คบ่ฝฎๅป“ใ€‚ 3. ๐ŸŽจ **ไธŠ่‰ฒ๏ผˆๅทฒ้€‰ๆ‹ฉ็”ป็ฌ”๏ผ‰**๏ผš็‚นๅ‡ปๅบ•้ƒจ้ขœ่‰ฒๆกไธญ็š„้ขœ่‰ฒๆˆ–่ฐƒ็”จ `ref.setPaintColor`๏ผˆๆˆ–้€š่ฟ‡ `initialPaintColor` ้ข„่ฎพ๏ผ‰๏ผŒ็„ถๅŽ็‚นๅ‡ปๅŒบๅŸŸไธŠ่‰ฒ๏ผ›ๅ†ๆฌก็‚นๅ‡ปๅŒไธ€ๅŒบๅŸŸไผš่ฆ†็›–้ขœ่‰ฒใ€‚ 4. ๐Ÿ’ฌ **ๆ— ็”ป็ฌ”็‚นๅ‡ป**๏ผšไธๆ‰ง่กŒไธŠ่‰ฒ๏ผ›`onPaintCallback` ่งฆๅ‘ `kind: 'brush_required'`๏ผŒๆบๅธฆๆ็คบไฟกๆฏๅ’Œ็›ฎๆ ‡ๅŒบๅŸŸไฟกๆฏ๏ผŒไพ›ๅฎฟไธปๆ˜พ็คบ Toast/ๅผน็ช—ๆ็คบ้€‰ๆ‹ฉ้ขœ่‰ฒใ€‚ 5. โ†ฉ๏ธ **ๆ’ค้”€**๏ผšๅทฆไธ‹ๆŒ‰้’ฎๆˆ– `ref.reset()`๏ผ›ๆŒ‰ไธŠ่‰ฒๅކๅฒ้€ๆญฅๅŽ้€€ใ€‚ 6. ๐Ÿ‘๏ธ **ไธŽๅŽŸๅ›พๅฏนๆฏ”**๏ผšๅณไธ‹ๆŒ‰้’ฎๆˆ– `ref.swap()`๏ผ›้š่—ไธŠ่‰ฒๅฑ‚ไปฅๆ˜พ็คบๅŽŸๅ›พใ€‚ + +## ๅฅ—็ดขๆจกๅผ๏ผˆๆ‰‹ๅŠจๅข™้ขๅˆ†ๅ‰ฒ๏ผ‰ + +ๅฝ“ `manualSplitWalls` ๅฏ็”จไธ”ๅฅ—็ดขๆจกๅผๆฟ€ๆดปๆ—ถ๏ผš + +7. ๐Ÿงฒ **่ฟ›ๅ…ฅๅฅ—็ดข**๏ผš่ฐƒ็”จ `ref.startLasso()` ๆฟ€ๆดปๅฅ—็ดขๆจกๅผใ€‚ๅฅ—็ดขๅคš่พนๅฝขๅ ๅŠ ๅฑ‚๏ผˆๆฉ™่‰ฒ๏ผ‰ๅ‡บ็Žฐใ€‚ +8. ๐Ÿ‘† **ๆ”พ็ฝฎ้กถ็‚น**๏ผš็‚นๅ‡ปๅข™้ขๅŒบๅŸŸๆ”พ็ฝฎๅคš่พนๅฝข้กถ็‚นใ€‚้กถ็‚นไผš่‡ชๅŠจๅธ้™„ๅˆฐๅข™้ข้ฎ็ฝฉ่พน็ผ˜/่ง’็‚นใ€‚ +9. ๐Ÿงฒ **็ฃๆ€งๅฅ—็ดข**๏ผˆๅฝ“ `magneticLasso: true`๏ผ‰๏ผš็‚นๅ‡ปไน‹้—ด็š„่ทฏๅพ„่‡ชๅŠจๆฒฟๅ›พๅƒๅผบ่พน็ผ˜่ตฐ๏ผˆ็ปฟ่‰ฒ่ทฏๅพ„ๅ ๅŠ ๅฑ‚๏ผ‰๏ผŒ้€š่ฟ‡ Sobel ๆขฏๅบฆ + Dijkstra ๆœ€็Ÿญ่ทฏๅพ„ๅฎž็Žฐใ€‚ +10. ๐Ÿ”’ **้—ญๅˆๅคš่พนๅฝข**๏ผš็‚นๅ‡ป็ฌฌไธ€ไธช้กถ็‚น้™„่ฟ‘้—ญๅˆๅคš่พนๅฝขใ€‚้—ญๅˆๅคš่พนๅฝขไปฅๆฉ™่‰ฒ่ฝฎๅป“ๆ˜พ็คบใ€‚ +11. โœ‹ **ๆ‹–ๆ‹ฝ้กถ็‚น**๏ผš่งฆๆ‘ธๅนถๆ‹–ๆ‹ฝๅทฒๆœ‰้กถ็‚น้‡ๆ–ฐๅฎšไฝใ€‚้กถ็‚นๅธ้™„ๅˆฐๅข™้ข่พน็•Œ/่ง’็‚น๏ผŒๆˆ–ไฟๆŒๅœจๅข™้ข้ฎ็ฝฉๅ†…้ƒจไฝ็ฝฎใ€‚ +12. โœ… **็ป“ๆŸๅฅ—็ดข**๏ผš่ฐƒ็”จ `ref.endLasso()` ๅฐ†ๆ‰€ๆœ‰้—ญๅˆๅคš่พนๅฝข่ฝฌๆขไธบๅฏไธŠ่‰ฒ็š„ `wall-N` ๅญๅŒบๅŸŸใ€‚ +13. ๐Ÿ—‘๏ธ **ๅ–ๆถˆๅฅ—็ดข**๏ผš่ฐƒ็”จ `ref.cancelLasso()` ไธขๅผƒๆ‰€ๆœ‰่ฟ›่กŒไธญ็š„ๅฅ—็ดขๅคš่พนๅฝข๏ผŒไธไฟๅญ˜ใ€‚ +14. ๐Ÿ—‘๏ธ **ๅˆ ้™คๅฅ—็ดข**๏ผš่ฐƒ็”จ `ref.deleteLasso(id)` ๅˆ ้™คไน‹ๅ‰ๆไบค็š„ๅฅ—็ดขๅคš่พนๅฝขๅŠๅ…ถๅ…ณ่”็š„ `wall-N` ๅŒบๅŸŸใ€‚ + +### ไธปๅŠจ่ฝฎๅป“็ฒพ็‚ผ + +ๅฝ“ `activeContourRefine: true` ๆ—ถ๏ผŒ้—ญๅˆๅฅ—็ดขๅคš่พนๅฝขๅœจ `endLasso()` ๅŽ่‡ชๅŠจ็ฒพ็‚ผ๏ผš + +- ๆฏไธช้กถ็‚นๆฒฟๅ…ถๅค–ๆณ•็บฟๆ–นๅ‘้‡‡ๆ ทไฝ็ฝฎ +- ้กถ็‚นๅ‘ๅค–ๆ‰ฉๅฑ•ๅˆฐๆœ€่ฟ‘็š„ๅข™้ข้ฎ็ฝฉ่พน็•Œ่พน็ผ˜๏ผˆๆฐ”็ƒ้˜Ÿๅˆ—ๅŠ›๏ผ‰ +- Douglas-Peucker ็ฎ€ๅŒ–ๅŽป้™คๅ†—ไฝ™้กถ็‚น +- ็ป“ๆžœ๏ผšๅคš่พนๅฝข่ดดๅˆ็œŸๅฎžๅข™้ข่ฝฎๅป“๏ผŒ่€Œ้žๅŽŸๅง‹็‚นๅ‡ปไฝ็ฝฎ diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md index 2d529ad..8491a3f 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/intro.md @@ -10,6 +10,7 @@ title: ๆฆ‚่ฟฐ - ๐Ÿง  **OpenCV** (`react-native-fast-opencv`)๏ผš้ฎ็ฝฉ่ฏญไน‰ๅธƒๅฑ€ใ€่ธข่„š็บฟไฟฎ่กฅใ€ๅŒบๅŸŸๆๅ– - ๐Ÿ–Œ๏ธ **Skia RuntimeEffect (SkSL)**๏ผšๅ• Pass ๅ…จๅฑ็€่‰ฒๅ™จ๏ผŒๆททๅˆๅŽŸๅ›พ + LAB ไฝŽ้ข‘/้ซ˜้ข‘็บน็†้ขœ่‰ฒๅ ๅŠ  - โœ‚๏ธ **Skia Path**๏ผšๅŒบๅŸŸ่™š็บฟ่ฝฎๅป“้ซ˜ไบฎ +- ๐Ÿงฒ **็ฃๆ€งๅฅ—็ดข๏ผˆMagnetic Lasso๏ผ‰**๏ผšๆ‰‹ๅŠจๅข™้ขๅˆ†ๅŒบ๏ผŒๆ”ฏๆŒ่พน็ผ˜ๅธ้™„๏ผˆSobel ๆขฏๅบฆ + Dijkstra ๆœ€็Ÿญ่ทฏๅพ„๏ผ‰ๅ’ŒไธปๅŠจ่ฝฎๅป“่พน็•Œ็ฒพ็‚ผ - ๐Ÿ‘† **ไบคไบ’**๏ผšๅบ•้ƒจ้ขœ่‰ฒๆก้€‰ๆ‹ฉ็”ป็ฌ”๏ผˆๅฏ้€‰ๅˆๅง‹ๅŒ–๏ผ‰โ†’ ็‚นๅ‡ปๅŒบๅŸŸไธŠ่‰ฒ๏ผ›ๆœช้€‰ๆ‹ฉ็”ป็ฌ”ๆ—ถ็‚นๅ‡ปไผš่งฆๅ‘ `onPaintCallback` ๅนถ้™„ๅธฆๆ็คบ๏ผ›ๆœช้€‰็”ป็ฌ”ๆ—ถ้•ฟๆŒ‰ๅฏ้ข„่งˆๅŒบๅŸŸ็š„่™š็บฟ่ฝฎๅป“ ๆœฌไป“ๅบ“ๅŒๆ—ถไฝœไธบ **ๅบ“ๆบ็ **๏ผˆ`src/index.ts`๏ผ‰ๅ’Œ **่‡ชๆต‹ Demo**๏ผˆๆ น็›ฎๅฝ• `App.tsx`๏ผ‰ใ€‚ @@ -26,8 +27,9 @@ title: ๆฆ‚่ฟฐ 2. ๐Ÿงฉ **ๅˆ†ๅ‰ฒ**้€š่ฟ‡ OpenCV ๅฐ†้ฎ็ฝฉๅˆ†ๅ‰ฒไธบ่ฏญไน‰ๅŒบๅŸŸ๏ผˆๅข™้ขใ€ๅคฉ่Šฑๆฟใ€่ธข่„š็บฟ็ญ‰๏ผ‰ 3. ๐ŸŽจ **ๅ‡†ๅค‡**้€š่ฟ‡ SkSL ็”Ÿๆˆ LAB ้ข‘ๅŸŸๅฑ‚็บน็†๏ผŒๅฎž็Žฐ้€ผ็œŸ็š„้ขœ่‰ฒๆททๅˆ 4. ๐Ÿ“ **ๆž„ๅปบ**ๆฏไธชๅŒบๅŸŸ็š„ Skia ่™š็บฟ่ฝฎๅป“่ทฏๅพ„ -5. ๐Ÿ‘† **ไบคไบ’** โ€” ็”จๆˆท้€‰ๆ‹ฉ็”ป็ฌ”้ขœ่‰ฒๅนถ็‚นๅ‡ปๅŒบๅŸŸไธŠ่‰ฒ๏ผ›ไธŠ่‰ฒๅฑ‚ไฟ็•™ๅบ•ๅฑ‚็บน็† -6. ๐Ÿ’พ **ไฟๅญ˜**ๅˆๆˆ็ป“ๆžœไธบ PNG๏ผ›ๅฏผๅ‡บ JSON ไผš่ฏ็”จไบŽ่‰็จฟๆขๅค +5. ๐Ÿงฒ **ๆ‰‹ๅŠจๅˆ†ๅ‰ฒ**๏ผˆๅฏ้€‰๏ผ‰โ€” ๅœจๅข™้ขไธŠ็ป˜ๅˆถๅฅ—็ดขๅคš่พนๅฝข๏ผŒๅฐ†ๅ…ถ็ป†ๅˆ†ไธบๅฏ็‹ฌ็ซ‹ไธŠ่‰ฒ็š„ `wall-N` ๅŒบๅŸŸ๏ผŒๅฏ้€‰่พน็ผ˜ๅธ้™„ๅ’ŒไธปๅŠจ่ฝฎๅป“็ฒพ็‚ผ +6. ๐Ÿ‘† **ไบคไบ’** โ€” ็”จๆˆท้€‰ๆ‹ฉ็”ป็ฌ”้ขœ่‰ฒๅนถ็‚นๅ‡ปๅŒบๅŸŸไธŠ่‰ฒ๏ผ›ไธŠ่‰ฒๅฑ‚ไฟ็•™ๅบ•ๅฑ‚็บน็† +7. ๐Ÿ’พ **ไฟๅญ˜**ๅˆๆˆ็ป“ๆžœไธบ PNG๏ผ›ๅฏผๅ‡บ JSON ไผš่ฏ็”จไบŽ่‰็จฟๆขๅค ็ป„ไปถ้€š่ฟ‡ `onWatch` ๅ‘ๅ‡บ Pipeline ็Šถๆ€่ฝฌๆข๏ผŒๅฎฟไธปๅบ”็”จๅฏๆฎๆญคๆ˜พ็คบ็›ธๅบ”็š„ๅŠ ่ฝฝ็Šถๆ€ใ€‚ diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/project-structure.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/project-structure.md index df1113d..f88aee5 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/project-structure.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/project-structure.md @@ -17,6 +17,9 @@ MaskSegmentApp/ # ไป“ๅบ“ๆ น็›ฎๅฝ•๏ผˆnpm ๅŒ… react-n โ”‚ โ”œโ”€โ”€ maskSegmentation.ts โ”‚ โ”œโ”€โ”€ maskSegmentRuntime.ts โ”‚ โ”œโ”€โ”€ maskSemanticPalette.ts +โ”‚ โ”œโ”€โ”€ magneticLasso.ts # ่พน็ผ˜ๅธ้™„ๅฅ—็ดข๏ผˆSobel + Dijkstra๏ผ‰ +โ”‚ โ”œโ”€โ”€ activeContour.ts # ไธปๅŠจ่ฝฎๅป“็ฒพ็‚ผ๏ผˆSnake + Balloon๏ผ‰ +โ”‚ โ”œโ”€โ”€ wallTextureSplit.ts # ่‡ชๅŠจไธŽๆ‰‹ๅŠจๅข™้ข็บน็†ๅˆ†ๅ‰ฒ โ”‚ โ””โ”€โ”€ ... โ”œโ”€โ”€ example/ # โ˜… ๆŽจ่๏ผšๆถˆ่ดนๆ–น้›†ๆˆ Demo โ”‚ โ”œโ”€โ”€ App.tsx # ไป…ไฝฟ็”จๅ…ฌๅผ€ API ็š„ๅฎŒๆ•ด็คบไพ‹ diff --git a/example/App.tsx b/example/App.tsx index f20900a..c9cd197 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -24,6 +24,7 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import MaskSegmentCanvas, { type BgrColor, + type ManualWallPartition, type MaskSegmentCanvasRef, type MaskSegmentSession, type MaskSegmentWatchState, @@ -122,6 +123,11 @@ function App(): React.JSX.Element { // Demo mode const [useCustomColors, setUseCustomColors] = useState(false); const [splitWalls, setSplitWalls] = useState(false); + const [manualSplitWalls, setManualSplitWalls] = useState(false); + const [magneticLasso, setMagneticLasso] = useState(false); + const [activeContourRefine, setActiveContourRefine] = useState(false); + const [splitEdgeBarrier, setSplitEdgeBarrier] = useState(false); + const [isLassoing, setIsLassoing] = useState(false); const [pipelinePreset, setPipelinePreset] = useState('medium'); const [groupIndex, setGroupIndex] = useState(0); @@ -272,6 +278,77 @@ function App(): React.JSX.Element { [showToast], ); + const handleStartLasso = useCallback(() => { + canvasRef.current?.startLasso(); + setIsLassoing(true); + showToast('Lasso mode: tap wall area to place vertices'); + }, [showToast]); + + const handleEndLasso = useCallback(() => { + const parts = canvasRef.current?.endLasso(); + setIsLassoing(false); + if (parts && parts.length > 0) { + showToast(`Lasso ended: ${parts.length} wall sub-regions created`); + console.log( + '[Example] Manual wall partitions:', + JSON.stringify( + parts.map(p => ({ id: p.id, regionName: p.regionName, area: p.area })), + null, + 2, + ), + ); + } else { + showToast('Lasso ended (no polygons to convert)'); + } + }, [showToast]); + + const handleCancelLasso = useCallback(() => { + canvasRef.current?.cancelLasso(); + setIsLassoing(false); + showToast('Lasso cancelled (regions not saved)'); + }, [showToast]); + + const handleDeleteLasso = useCallback(() => { + const parts = canvasRef.current?.getManualRegions(); + if (!parts || parts.length === 0) { + showToast('No lasso polygons to delete'); + return; + } + const last = parts[parts.length - 1]; + canvasRef.current?.deleteLasso(last.id); + showToast(`Deleted lasso: ${last.regionName}`); + }, [showToast]); + + const handleGetLassoRegions = useCallback(() => { + const parts = canvasRef.current?.getManualRegions(); + if (!parts || parts.length === 0) { + Alert.alert('Manual Regions', 'No manual wall partitions available.'); + return; + } + const summary = parts + .map( + (p: ManualWallPartition) => + ` ${p.regionName}: area=${p.area}, bbox=(${p.bbox.x},${p.bbox.y} ${p.bbox.w}x${p.bbox.h})`, + ) + .join('\n'); + Alert.alert('Manual Wall Partitions', `${parts.length} regions:\n${summary}`); + console.log( + '[Example] getManualRegions:', + JSON.stringify( + parts.map(p => ({ + id: p.id, + regionId: p.regionId, + regionName: p.regionName, + area: p.area, + bbox: p.bbox, + vertexCount: p.vertices.length, + })), + null, + 2, + ), + ); + }, []); + // -------------------------------------------------------------------------- // render: error / loading / ready // -------------------------------------------------------------------------- @@ -381,13 +458,65 @@ function App(): React.JSX.Element { (split walls) + { + setManualSplitWalls(v => !v); + if (!manualSplitWalls) { + setSplitWalls(false); + } + }} + > + + (manual split) + + + { + setMagneticLasso(v => !v); + if (!magneticLasso) { + setManualSplitWalls(true); + } + }} + > + + (magnetic) + + + { + setActiveContourRefine(v => !v); + if (!activeContourRefine) { + setManualSplitWalls(true); + } + }} + > + + (contour) + + + { + setSplitEdgeBarrier(v => !v); + if (!splitEdgeBarrier) { + setSplitWalls(true); + } + }} + > + + (edge barrier) + + {/* canvas */} Export session + + + + {/* Lasso operations */} + Lasso: + + Start Lasso + + + End Lasso + + + Cancel Lasso + + + Del Lasso + + + Get Regions + @@ -585,6 +759,9 @@ const styles = StyleSheet.create({ modeChipActive: { backgroundColor: '#4363D8', }, + modeChipLassoActive: { + backgroundColor: '#00C853', + }, modeChipText: { fontSize: 11, color: '#666', @@ -697,6 +874,10 @@ const styles = StyleSheet.create({ actionBtnDanger: { borderColor: '#e88', }, + actionBtnLassoActive: { + backgroundColor: '#FF6B35', + borderColor: '#FF6B35', + }, actionBtnText: { fontSize: 12, color: '#555', diff --git a/package.json b/package.json index e848e32..cd1fc80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-mask-segment-canvas", - "version": "0.3.0", + "version": "0.4.0", "description": "React Native mask segmentation canvas library: OpenCV semantic segmentation + SkSL Shader coloring", "main": "dist/index.js", "module": "dist/index.js", diff --git a/src/components/MaskSegmentCanvas.tsx b/src/components/MaskSegmentCanvas.tsx index 4e206f5..f67b56a 100644 --- a/src/components/MaskSegmentCanvas.tsx +++ b/src/components/MaskSegmentCanvas.tsx @@ -23,7 +23,30 @@ import { type RegionMaskData, type SegmentRegion, } from '../utils/maskSegmentation'; -import { splitWallRegionsByTexture } from '../utils/wallTextureSplit'; +import { + splitWallRegionsByTexture, + WALL_SUB_LABEL_NONE, + buildPickMapAfterWallSplit, + dilatePickBuffer1px, + patchPickMapForManualWallSplit, + absorbSmallWallGapsForLassoPolygons, +} from '../utils/wallTextureSplit'; +import { + buildEnergyMap, + findShortestPath, + extractCornerPoints, + normToEnergyPoint, + energyPointsToNorm, + isNormPointOnWallMask, + filterVerticesToWallMask, + buildWallAllowedMask, + snapNormPointToWallEdge, + snapNormPointToWallCornerOrEdge, + resolveLassoWallDragPoint, + type EnergyMap, + type WallMaskSample, +} from '../utils/magneticLasso'; +import { refinePolygonToWallEdges } from '../utils/activeContour'; import { clearDerivedImageCache, readPngBgrBuffer, @@ -53,6 +76,8 @@ import { } from '../utils/maskSegmentRuntime'; import type { BgrColor, + LassoPolygon, + ManualWallPartition, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentSession, @@ -110,6 +135,208 @@ import { timeLog, } from '../utils/canvasGeometry'; +type LassoVertexHit = { + kind: 'open' | 'closed'; + polyId?: string; + vertexIndex: number; +}; + +function canvasDistToNormVertex( + canvasX: number, + canvasY: number, + nx: number, + ny: number, + cw: number, + ch: number, + imgW: number, + imgH: number, +): number { + const r = getContainRect(cw, ch, imgW, imgH); + const vx = r.x + nx * r.w; + const vy = r.y + ny * r.h; + return Math.hypot(canvasX - vx, canvasY - vy); +} + +function isNearOpenLassoFirstVertex( + canvasX: number, + canvasY: number, + cw: number, + ch: number, + imgW: number, + imgH: number, + openVerts: { x: number; y: number }[] | null, + thresholdPx: number, +): boolean { + if (!openVerts || openVerts.length < 3) { + return false; + } + const first = openVerts[0]; + return ( + canvasDistToNormVertex( + canvasX, canvasY, first.x, first.y, cw, ch, imgW, imgH, + ) < thresholdPx + ); +} + +function findLassoVertexHit( + canvasX: number, + canvasY: number, + cw: number, + ch: number, + imgW: number, + imgH: number, + thresholdPx: number, + openVerts: { x: number; y: number }[] | null, + closedPolys: Map, + options?: { openOnly?: boolean }, +): LassoVertexHit | null { + const r = getContainRect(cw, ch, imgW, imgH); + let bestDist = Infinity; + let result: LassoVertexHit | null = null; + + const tryVertex = ( + nx: number, + ny: number, + kind: 'open' | 'closed', + vertexIndex: number, + polyId?: string, + ) => { + const vx = r.x + nx * r.w; + const vy = r.y + ny * r.h; + const dist = Math.hypot(canvasX - vx, canvasY - vy); + if (dist <= thresholdPx && dist < bestDist) { + bestDist = dist; + result = { kind, polyId, vertexIndex }; + } + }; + + if (openVerts) { + for (let i = 0; i < openVerts.length; i++) { + tryVertex(openVerts[i].x, openVerts[i].y, 'open', i); + } + } + if (!options?.openOnly) { + for (const [polyId, poly] of closedPolys) { + for (let i = 0; i < poly.vertices.length; i++) { + tryVertex(poly.vertices[i].x, poly.vertices[i].y, 'closed', i, polyId); + } + } + } + return result; +} + +function buildWallMaskSampleFromRef( + maskData: RegionMaskData | null, +): WallMaskSample | null { + if (!maskData) return null; + const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors; + const wallSemanticIdx = + maskData.wallSemanticIdx ?? + semanticColors.findIndex(sc => sc.name === 'wall'); + if (wallSemanticIdx < 0) return null; + return { + labels: maskData.labels, + baseboardBinary: maskData.baseboardBinary, + cols: maskData.cols, + rows: maskData.rows, + wallSemanticIdx, + }; +} + +function isNormPointOnAssignedWall( + normX: number, + normY: number, + maskData: RegionMaskData | null, +): boolean { + if (!maskData?.wallSubLabels || maskData.cols <= 0 || maskData.rows <= 0) { + return false; + } + const { wallSubLabels, cols, rows } = maskData; + 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))); + return wallSubLabels[cy * cols + cx] !== WALL_SUB_LABEL_NONE; +} + +function isNormPointInCommittedLassoArea( + normX: number, + normY: number, + committedParts: ManualWallPartition[], + sessionClosedPolys: Map, + excludePolyId?: string, +): boolean { + for (const part of committedParts) { + if ( + part.vertices.length >= 3 && + pointInPolygon(normX, normY, part.vertices) + ) { + return true; + } + } + for (const [polyId, poly] of sessionClosedPolys) { + if (polyId === excludePolyId || !poly.isClosed || poly.vertices.length < 3) { + continue; + } + if (pointInPolygon(normX, normY, poly.vertices)) { + return true; + } + } + return false; +} + +function canPlaceLassoPointAt( + normX: number, + normY: number, + maskData: RegionMaskData | null, + wallMask: WallMaskSample | null, + committedParts: ManualWallPartition[], + sessionPolys: Map, +): boolean { + if (!wallMask || !isNormPointOnWallMask(normX, normY, wallMask)) { + return false; + } + if (isNormPointOnAssignedWall(normX, normY, maskData)) { + return false; + } + return !isNormPointInCommittedLassoArea( + normX, normY, committedParts, sessionPolys, + ); +} +function lassoPolygonUsesCommittedArea( + vertices: { x: number; y: number }[], + maskData: RegionMaskData | null, + committedParts: ManualWallPartition[], + sessionPolys: Map, + excludePolyId?: string, +): boolean { + for (const v of vertices) { + if (isNormPointOnAssignedWall(v.x, v.y, maskData)) { + return true; + } + if ( + isNormPointInCommittedLassoArea( + v.x, v.y, committedParts, sessionPolys, excludePolyId, + ) + ) { + return true; + } + } + if (vertices.length >= 3) { + const cx = vertices.reduce((s, v) => s + v.x, 0) / vertices.length; + const cy = vertices.reduce((s, v) => s + v.y, 0) / vertices.length; + if (isNormPointOnAssignedWall(cx, cy, maskData)) { + return true; + } + if ( + isNormPointInCommittedLassoArea( + cx, cy, committedParts, sessionPolys, excludePolyId, + ) + ) { + return true; + } + } + return false; +} + /* ========================================================================== * component * ========================================================================== */ @@ -355,6 +582,7 @@ const MaskSegmentCanvas = forwardRef>(new Map()); const [paintHistory, setPaintHistory] = useState([]); + const paintHistoryRef = useRef([]); // Seed the ref with the initial empty map so early reads (before any paint effect) // are consistent. @@ -389,6 +617,9 @@ const MaskSegmentCanvas = forwardRef { paintedRegionsRef.current = paintedRegions; }, [paintedRegions]); + useEffect(() => { + paintHistoryRef.current = paintHistory; + }, [paintHistory]); // Cached export from the most recent auto-export or save() โ€” keyed by painted fingerprint. const lastExportCacheRef = useRef<{ fingerprint: string; result: SavePaintResult } | null>(null); @@ -472,8 +703,44 @@ const MaskSegmentCanvas = forwardRef(null); + // โ”€โ”€ Manual Lasso State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [isLassoActive, setIsLassoActive] = useState(false); + const isLassoActiveRef = useRef(false); + const [lassoPolygons, setLassoPolygons] = useState>(new Map()); + const lassoPolygonsRef = useRef>(new Map()); + const [currentLassoVertices, setCurrentLassoVertices] = useState<{ x: number; y: number }[] | null>(null); + const currentLassoVerticesRef = useRef<{ x: number; y: number }[] | null>(null); + const [manualWallRegions, setManualWallRegions] = useState([]); + const manualWallRegionsRef = useRef([]); + const lassoIdCounterRef = useRef(0); + const LASSO_COLOR = '#FF6B35'; + const MAGNETIC_LASSO_COLOR = '#00C853'; + const LASSO_CLOSE_THRESHOLD_PX = 32; + const LASSO_VERTEX_HIT_PX = 20; + const LASSO_DRAG_ACTIVATE_PX = 10; + const LASSO_MIN_VERTEX_SPACING_PX = 14; + const LASSO_EDGE_SNAP_SEG_PX = 12; + const LASSO_TAP_SNAP_SEG_PX = 20; + const LASSO_TAP_CANCEL_PX = 8; + + const lassoDragRef = useRef(null); + const lassoVertexCandidateRef = useRef(null); + const lassoDragMovedRef = useRef(false); + const lassoPendingTapRef = useRef<{ x: number; y: number } | null>(null); + const [lassoDragVertex, setLassoDragVertex] = useState( + null, + ); + + const [energyMap, setEnergyMap] = useState(null); + const energyMapRef = useRef(null); + useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]); useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]); + useEffect(() => { isLassoActiveRef.current = isLassoActive; }, [isLassoActive]); + useEffect(() => { lassoPolygonsRef.current = lassoPolygons; }, [lassoPolygons]); + useEffect(() => { currentLassoVerticesRef.current = currentLassoVertices; }, [currentLassoVertices]); + useEffect(() => { manualWallRegionsRef.current = manualWallRegions; }, [manualWallRegions]); + useEffect(() => { energyMapRef.current = energyMap; }, [energyMap]); const handleCanvasWrapLayout = useCallback((width: number, height: number) => { if (width <= 0 || height <= 0) { @@ -511,6 +778,8 @@ const MaskSegmentCanvas = forwardRef(null); + const lastOutlineRegionKeyRef = useRef(''); + const [regionPickGeneration, setRegionPickGeneration] = useState(0); useEffect(() => { paintResourceLayersRef.current = paintResourceLayers; @@ -578,7 +847,13 @@ const MaskSegmentCanvas = forwardRef>(), @@ -624,6 +899,17 @@ const MaskSegmentCanvas = forwardRef sc.name); + const segWallSemanticIdx = segIndexToName.indexOf('wall'); regionMaskDataRef.current = { labels: segmentResult.labels, baseboardBinary: segmentResult.baseboardBinary, cols: segmentResult.segCols, rows: segmentResult.segRows, wallSubLabels: segmentResult.wallSubLabels, + indexToName: segIndexToName, + wallSemanticIdx: segWallSemanticIdx >= 0 ? segWallSemanticIdx : undefined, }; baseboardPickMaskRef.current = null; kickRegionIdRef.current = @@ -1297,27 +1590,24 @@ const MaskSegmentCanvas = forwardRef + id != null && regionsRef.current.some(r => r.id === id); + const regionPick = regionPickRef.current; + const pickRadius = strict + ? 0 + : getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx; + if (regionPick) { - const pickHit = lookupRegionFromPickMap( - norm.x, - norm.y, - regionPick, - strict - ? 0 - : getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx, - ); - if (pickHit != null) { - return pickHit; + const centerHit = lookupRegionFromPickMap(norm.x, norm.y, regionPick, 0); + if (isValidRegionId(centerHit)) { + return centerHit; } if (strict) { return null; } } - const pick = maskPickRef.current; - const kickId = kickRegionIdRef.current; - if (!strict) { const polygonHit = resolveRegionHit(regionsRef.current, norm.x, norm.y); if (polygonHit != null) { @@ -1325,6 +1615,21 @@ const MaskSegmentCanvas = forwardRef 0) { + const radiusHit = lookupRegionFromPickMap( + norm.x, + norm.y, + regionPick, + pickRadius, + ); + if (isValidRegionId(radiusHit)) { + return radiusHit; + } + } + + const pick = maskPickRef.current; + const kickId = kickRegionIdRef.current; + if (pick && kickId != null) { const pickMask = baseboardPickMaskRef.current; const kickHit = pickKickRegionFromMask( @@ -1620,6 +1925,599 @@ const MaskSegmentCanvas = forwardRef [...regionsRef.current], getPaintedRegions: () => buildPaintedRecords(), + startLasso: () => { + setLassoPolygons(new Map()); + lassoPolygonsRef.current = new Map(); + setCurrentLassoVertices(null); + currentLassoVerticesRef.current = null; + // Keep manualWallRegions / existing wall-N partitions โ€” each session adds more. + setIsLassoActive(true); + isLassoActiveRef.current = true; + + // Precompute energy map for magnetic lasso + const isMagnetic = getMaskSegmentRuntimeConfig().mask.magneticLasso; + if (isMagnetic && getMaskSegmentRuntimeConfig().mask.manualSplitWalls) { + const work = workBufferRef.current; + if (work && work.cols > 0 && work.rows > 0) { + const maskData = regionMaskDataRef.current; + const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors; + const wallSemanticIdx = + maskData?.wallSemanticIdx ?? + semanticColors.findIndex(sc => sc.name === 'wall'); + let allowedMask: Uint8Array | null = null; + if ( + maskData && + wallSemanticIdx >= 0 && + maskData.cols === work.cols && + maskData.rows === work.rows + ) { + allowedMask = buildWallAllowedMask( + maskData.labels, + maskData.baseboardBinary, + wallSemanticIdx, + ); + } + const map = buildEnergyMap( + work.buffer, + work.cols, + work.rows, + 256, + allowedMask, + ); + setEnergyMap(map); + energyMapRef.current = map; + } + } + }, + cancelLasso: () => { + setIsLassoActive(false); + isLassoActiveRef.current = false; + setCurrentLassoVertices(null); + currentLassoVerticesRef.current = null; + setLassoPolygons(new Map()); + lassoPolygonsRef.current = new Map(); + setEnergyMap(null); + energyMapRef.current = null; + lassoDragRef.current = null; + lassoPendingTapRef.current = null; + lassoVertexCandidateRef.current = null; + lassoDragMovedRef.current = false; + setLassoDragVertex(null); + }, + endLasso: () => { + const maskDataEarly = regionMaskDataRef.current; + const semanticColorsEarly = getMaskSegmentRuntimeConfig().mask.semanticColors; + const wallIdxEarly = + maskDataEarly?.wallSemanticIdx ?? + semanticColorsEarly.findIndex(sc => sc.name === 'wall'); + const wallMaskEarly: WallMaskSample | null = + maskDataEarly && wallIdxEarly >= 0 + ? { + labels: maskDataEarly.labels, + baseboardBinary: maskDataEarly.baseboardBinary, + cols: maskDataEarly.cols, + rows: maskDataEarly.rows, + wallSemanticIdx: wallIdxEarly, + } + : null; + const hasEnoughWallVerts = (verts: { x: number; y: number }[]) => { + if (verts.length < 3) return false; + if (!wallMaskEarly) return true; + return filterVerticesToWallMask(verts, wallMaskEarly).length >= 3; + }; + + // 1. Finalize open polygon if possible (keep raw vertices for rasterization) + const cur = currentLassoVerticesRef.current; + if (cur && hasEnoughWallVerts(cur)) { + const id = `lasso_${++lassoIdCounterRef.current}`; + const polygon: LassoPolygon = { + id, + vertices: [...cur], + isClosed: true, + }; + const nextPolys = new Map(lassoPolygonsRef.current); + nextPolys.set(id, polygon); + lassoPolygonsRef.current = nextPolys; + setLassoPolygons(nextPolys); + setCurrentLassoVertices(null); + currentLassoVerticesRef.current = null; + } + + // 2. Collect closed polygons + const polys = new Map( + [...lassoPolygonsRef.current.entries()].filter(([, poly]) => + hasEnoughWallVerts(poly.vertices), + ), + ); + if (polys.size === 0) { + return [...manualWallRegionsRef.current]; + } + + // 4. Get necessary data + const regions = regionsRef.current; + const wallTemplate = + regions.find(r => r.name === 'wall') ?? + regions.find(r => /^wall-\d+$/.test(r.name)); + if (!wallTemplate) { + return [...manualWallRegionsRef.current]; + } + + const wallHex = wallTemplate.hex; + const wallColor = { ...wallTemplate.color }; + + const maskData = regionMaskDataRef.current; + if (!maskData) { + return [...manualWallRegionsRef.current]; + } + + const { labels, baseboardBinary, cols: segW, rows: segH } = maskData; + const segPixelCount = segW * segH; + + // 3. Active contour refinement: expand polygon vertices outward toward wall boundary + const activeContourRefine = getMaskSegmentRuntimeConfig().mask.activeContourRefine; + if (activeContourRefine && wallMaskEarly) { + for (const [, poly] of polys) { + poly.vertices = refinePolygonToWallEdges(poly.vertices, wallMaskEarly); + } + } + + // Find wall semantic index in the label buffer (prefer value captured at segmentation). + const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors; + const polyEntries = [...polys.entries()]; + const polyVertsList = polyEntries.map(([, poly]) => poly.vertices); + + const inferDominantLabelInPolys = (): number => { + const labelCounts = new Map(); + for (let y = 0; y < segH; y++) { + for (let x = 0; x < segW; x++) { + const normX = (x + 0.5) / segW; + const normY = (y + 0.5) / segH; + let inside = false; + for (const verts of polyVertsList) { + if (pointInPolygon(normX, normY, verts)) { + inside = true; + break; + } + } + if (!inside) continue; + const label = labels[y * segW + x]; + if (label === 255) continue; + labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + } + } + let bestLabel = -1; + let bestCount = 0; + for (const [label, count] of labelCounts) { + if (count > bestCount) { + bestLabel = label; + bestCount = count; + } + } + return bestLabel; + }; + + const indexToName = + maskData.indexToName ?? semanticColors.map(sc => sc.name); + + let wallSemanticIdx = + maskData.wallSemanticIdx ?? + semanticColors.findIndex(sc => sc.name === 'wall'); + if (wallSemanticIdx < 0) { + const inferred = inferDominantLabelInPolys(); + if (inferred >= 0 && indexToName[inferred] === 'wall') { + wallSemanticIdx = inferred; + } + } + if (wallSemanticIdx < 0) { + return [...manualWallRegionsRef.current]; + } + + const rasterizeNewWallAreas = ( + wallIdx: number, + assignedLabels: Uint8Array, + ) => { + areas.fill(0); + for (const b of bboxes) { + b.x = segW; + b.y = segH; + b.w = 0; + b.h = 0; + } + + for (let y = 0; y < segH; y++) { + for (let x = 0; x < segW; x++) { + const i = y * segW + x; + if (labels[i] !== wallIdx) continue; + if (baseboardBinary[i]) continue; + // Skip wall pixels already assigned to a previous lasso session. + if (assignedLabels[i] !== WALL_SUB_LABEL_NONE) continue; + + const normX = (x + 0.5) / segW; + const normY = (y + 0.5) / segH; + + for (let pi = 0; pi < polyEntries.length; pi++) { + const poly = polyEntries[pi][1]; + if (pointInPolygon(normX, normY, poly.vertices)) { + newPolyLabels[i] = pi; + areas[pi]++; + const b = bboxes[pi]; + if (x < b.x) b.x = x; + if (y < b.y) b.y = y; + const right = x + 1; + const bottom = y + 1; + if (right > b.x + b.w) b.w = right - b.x; + if (bottom > b.y + b.h) b.h = bottom - b.y; + break; + } + } + } + } + }; + + // 5. Preserve existing wall sub-labels; rasterize only NEW unassigned wall pixels. + const remappedWallSubLabels = new Uint8Array(segPixelCount); + if ( + maskData.wallSubLabels && + maskData.wallSubLabels.length === segPixelCount + ) { + remappedWallSubLabels.set(maskData.wallSubLabels); + } else { + remappedWallSubLabels.fill(WALL_SUB_LABEL_NONE); + } + + let maxExistingSub = -1; + for (let i = 0; i < segPixelCount; i++) { + const sub = remappedWallSubLabels[i]; + if (sub !== WALL_SUB_LABEL_NONE) { + maxExistingSub = Math.max(maxExistingSub, sub); + } + } + + const existingWallRegions = regions.filter(r => + /^wall-\d+$/.test(r.name), + ); + let maxWallNum = 0; + for (const reg of existingWallRegions) { + const m = /^wall-(\d+)$/.exec(reg.name); + if (m) { + maxWallNum = Math.max(maxWallNum, Number(m[1])); + } + } + + const newPolyLabels = new Uint8Array(segPixelCount); + newPolyLabels.fill(WALL_SUB_LABEL_NONE); + const areas: number[] = new Array(polyEntries.length).fill(0); + const bboxes = polyEntries.map(() => ({ + x: segW, y: segH, w: 0, h: 0, + })); + + rasterizeNewWallAreas(wallSemanticIdx, remappedWallSubLabels); + + // Retry with inferred label if name-based index matched no new wall pixels + if (areas.every(a => a === 0)) { + const inferred = inferDominantLabelInPolys(); + if ( + inferred >= 0 && + indexToName[inferred] === 'wall' && + inferred !== wallSemanticIdx + ) { + wallSemanticIdx = inferred; + newPolyLabels.fill(WALL_SUB_LABEL_NONE); + rasterizeNewWallAreas(wallSemanticIdx, remappedWallSubLabels); + } + } + + const gapAbsorbDilatePx = + getMaskSegmentRuntimeConfig().mask.manualSplitWallsGapAbsorbDilatePx ?? + 5; + if (gapAbsorbDilatePx > 0 && polyEntries.length > 0) { + absorbSmallWallGapsForLassoPolygons( + newPolyLabels, + polyEntries.length, + areas, + bboxes, + labels, + baseboardBinary, + wallSemanticIdx, + remappedWallSubLabels, + segW, + segH, + gapAbsorbDilatePx, + ); + } + + // 6. Keep new polygons that captured unassigned wall pixels + const maxCount = + getMaskSegmentRuntimeConfig().mask.manualSplitWallsMaxCount ?? 8; + const slotsForNew = Math.max(0, maxCount - existingWallRegions.length); + const keptPolyIndices = areas + .map((area, idx) => ({ area, idx })) + .filter(entry => entry.area > 0) + .sort((a, b) => b.area - a.area) + .slice(0, slotsForNew) + .map(entry => entry.idx); + + if (keptPolyIndices.length === 0) { + return [...manualWallRegionsRef.current]; + } + + for (let i = 0; i < segPixelCount; i++) { + const polyIdx = newPolyLabels[i]; + if (polyIdx === WALL_SUB_LABEL_NONE) continue; + const rank = keptPolyIndices.indexOf(polyIdx); + if (rank >= 0) { + remappedWallSubLabels[i] = maxExistingSub + 1 + rank; + } + } + + const nonWallRegions = regions.filter( + r => r.name !== 'wall' && !/^wall-\d+$/.test(r.name), + ); + const maxRegionId = regions.reduce( + (max, r) => Math.max(max, r.id), + -1, + ); + const newWallSubRegions: SegmentRegion[] = keptPolyIndices.map( + (origIdx, rank) => { + const [, poly] = polyEntries[origIdx]; + const b = bboxes[origIdx]; + return { + id: maxRegionId + 1 + rank, + name: `wall-${maxWallNum + rank + 1}`, + hex: wallHex, + color: wallColor, + polygons: [poly.vertices.map(v => ({ x: v.x, y: v.y }))], + outlinePolygons: [poly.vertices.map(v => ({ x: v.x, y: v.y }))], + bbox: { + x: b.x / segW, + y: b.y / segH, + w: b.w / segW, + h: b.h / segH, + }, + area: areas[origIdx], + }; + }, + ); + + // Keep non-wall + existing wall-N; append new wall-(N+1)โ€ฆ + const mergedRegions = [ + ...nonWallRegions, + ...existingWallRegions, + ...newWallSubRegions, + ]; + + // 8. Patch wall pixels in the existing pick map (non-wall codes stay untouched). + const nameToId = new Map(mergedRegions.map(reg => [reg.name, reg.id])); + const existingPick = regionPickRef.current; + let newPickBuffer: Uint8Array; + if ( + existingPick && + existingPick.cols === segW && + existingPick.rows === segH && + existingPick.buffer.length === segPixelCount + ) { + newPickBuffer = patchPickMapForManualWallSplit( + existingPick.buffer, + labels, + baseboardBinary, + wallSemanticIdx, + remappedWallSubLabels, + nameToId, + segW, + segH, + ); + } else { + const pickRaw = buildPickMapAfterWallSplit( + labels, + baseboardBinary, + wallSemanticIdx, + remappedWallSubLabels, + indexToName, + nameToId, + segW, + segH, + ); + newPickBuffer = dilatePickBuffer1px(pickRaw, segW, segH); + } + + // Drop only the monolithic "wall" paint; keep existing wall-N paints. + const keptPainted = new Map(); + for (const [regionId, color] of paintedRegionsRef.current) { + const reg = regions.find(r => r.id === regionId); + if (!reg) continue; + if (reg.name === 'wall') { + paintedRegionConfigRef.current.delete(regionId); + continue; + } + keptPainted.set(regionId, color); + } + paintedRegionsRef.current = keptPainted; + + const keptHistory = paintHistoryRef.current.filter(regionId => { + const reg = regions.find(r => r.id === regionId); + return reg != null && reg.name !== 'wall'; + }); + + // 9. Update refs and state + regionsRef.current = mergedRegions; + regionPickRef.current = { buffer: newPickBuffer, cols: segW, rows: segH }; + kickRegionIdRef.current = + mergedRegions.find(reg => reg.thinStrip)?.id ?? null; + regionMaskDataRef.current = { + labels: maskData.labels, + baseboardBinary: maskData.baseboardBinary, + cols: segW, + rows: segH, + wallSubLabels: remappedWallSubLabels, + indexToName, + wallSemanticIdx, + }; + + // Keep paint preview in sync with the rebuilt pick buffer (same pick used by shader). + if (keptPainted.size > 0) { + paintColorMapSkImgRef.current?.dispose(); + paintColorMapSkImgRef.current = createPaintColorMapForPaint( + newPickBuffer, + segW, + segH, + keptPainted, + ); + } + + // 10. Build ManualWallPartition results (append to previous sessions) + const wallRegionNameToRegion = new Map( + mergedRegions + .filter(r => /^wall-\d+$/.test(r.name)) + .map(r => [r.name, r] as const), + ); + const newManualParts: ManualWallPartition[] = keptPolyIndices + .map((origIdx, rank) => { + const [polyId, poly] = polyEntries[origIdx]; + const regionName = `wall-${maxWallNum + rank + 1}`; + const segRegion = wallRegionNameToRegion.get(regionName); + if (!segRegion) return null; + return { + id: polyId, + regionId: segRegion.id, + regionName: segRegion.name, + vertices: poly.vertices, + bbox: segRegion.bbox, + area: segRegion.area, + }; + }) + .filter((p): p is ManualWallPartition => p != null); + + const allManualParts = [ + ...manualWallRegionsRef.current, + ...newManualParts, + ]; + + setManualWallRegions(allManualParts); + manualWallRegionsRef.current = allManualParts; + setRegionPalette(mergedRegions); + setRegionCount(mergedRegions.length); + setPaintedRegions(keptPainted); + setPaintHistory(keptHistory); + setRegionPickGeneration(g => g + 1); + // Force outline path rebuild (region IDs/names changed even if canvas layout did not). + maskPathsContainRectRef.current = null; + lastOutlineRegionKeyRef.current = ''; + + // Exit lasso mode only after a successful commit + setIsLassoActive(false); + isLassoActiveRef.current = false; + setCurrentLassoVertices(null); + currentLassoVerticesRef.current = null; + setEnergyMap(null); + energyMapRef.current = null; + setLassoPolygons(new Map()); + lassoPolygonsRef.current = new Map(); + + return allManualParts; + }, + getManualRegions: () => [...manualWallRegionsRef.current], + deleteLasso: (id: string) => { + if (lassoPolygonsRef.current.has(id)) { + setLassoPolygons(prev => { + const next = new Map(prev); + next.delete(id); + lassoPolygonsRef.current = next; + return next; + }); + return; + } + + const part = manualWallRegionsRef.current.find(p => p.id === id); + if (!part) { + return; + } + + const regions = regionsRef.current; + const maskData = regionMaskDataRef.current; + const pick = regionPickRef.current; + if (!maskData || !pick) { + return; + } + + const subMatch = /^wall-(\d+)$/.exec(part.regionName); + if (!subMatch) { + return; + } + const subToDelete = Number(subMatch[1]) - 1; + + const { labels, baseboardBinary, cols: segW, rows: segH } = maskData; + const segPixelCount = segW * segH; + const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors; + const indexToName = + maskData.indexToName ?? semanticColors.map(sc => sc.name); + let wallSemanticIdx = + maskData.wallSemanticIdx ?? + semanticColors.findIndex(sc => sc.name === 'wall'); + if (wallSemanticIdx < 0) { + return; + } + + const wallSubLabels = new Uint8Array( + maskData.wallSubLabels?.length === segPixelCount + ? maskData.wallSubLabels + : new Uint8Array(segPixelCount).fill(WALL_SUB_LABEL_NONE), + ); + for (let i = 0; i < segPixelCount; i++) { + if (wallSubLabels[i] === subToDelete) { + wallSubLabels[i] = WALL_SUB_LABEL_NONE; + } + } + + const mergedRegions = regions.filter(r => r.id !== part.regionId); + const nameToId = new Map(mergedRegions.map(reg => [reg.name, reg.id])); + + const newPickBuffer = patchPickMapForManualWallSplit( + pick.buffer, + labels, + baseboardBinary, + wallSemanticIdx, + wallSubLabels, + nameToId, + segW, + segH, + ); + + const keptPainted = new Map(paintedRegionsRef.current); + keptPainted.delete(part.regionId); + paintedRegionConfigRef.current.delete(part.regionId); + paintedRegionsRef.current = keptPainted; + + const keptHistory = paintHistoryRef.current.filter( + regionId => regionId !== part.regionId, + ); + paintHistoryRef.current = keptHistory; + + regionsRef.current = mergedRegions; + regionPickRef.current = { buffer: newPickBuffer, cols: segW, rows: segH }; + regionMaskDataRef.current = { + labels: maskData.labels, + baseboardBinary: maskData.baseboardBinary, + cols: segW, + rows: segH, + wallSubLabels, + indexToName, + wallSemanticIdx, + }; + + const allManualParts = manualWallRegionsRef.current.filter( + p => p.id !== id, + ); + manualWallRegionsRef.current = allManualParts; + + setManualWallRegions(allManualParts); + setRegionPalette(mergedRegions); + setRegionCount(mergedRegions.length); + setPaintedRegions(keptPainted); + setPaintHistory(keptHistory); + setRegionPickGeneration(g => g + 1); + maskPathsContainRectRef.current = null; + lastOutlineRegionKeyRef.current = ''; + }, }), [ undoSelection, @@ -1656,7 +2554,11 @@ const MaskSegmentCanvas = forwardRef `${r.id}:${r.name}`) + .join('|'); if ( + lastOutlineRegionKeyRef.current === regionLayoutKey && maskPathsContainRectRef.current && rectsEqual(maskPathsContainRectRef.current, containRect) ) { @@ -1675,6 +2577,7 @@ const MaskSegmentCanvas = forwardRef { + const elements: React.ReactNode[] = []; + const imgW = imageSize.w; + const imgH = imageSize.h; + const r = containRect; + + // Helper: normalized โ†’ canvas + const n2c = (nx: number, ny: number) => ({ + x: r.x + nx * r.w, + y: r.y + ny * r.h, + }); + + const lassoRenderColor = energyMapRef.current ? MAGNETIC_LASSO_COLOR : LASSO_COLOR; + + const maskDataForRender = regionMaskDataRef.current; + const wallIdxForRender = + maskDataForRender?.wallSemanticIdx ?? + getMaskSegmentRuntimeConfig() + .mask.semanticColors.findIndex(sc => sc.name === 'wall'); + const wallMaskForRender: WallMaskSample | null = + maskDataForRender && wallIdxForRender >= 0 + ? { + labels: maskDataForRender.labels, + baseboardBinary: maskDataForRender.baseboardBinary, + cols: maskDataForRender.cols, + rows: maskDataForRender.rows, + wallSemanticIdx: wallIdxForRender, + } + : null; + const visibleWallVerts = (verts: { x: number; y: number }[]) => + wallMaskForRender + ? filterVerticesToWallMask(verts, wallMaskForRender) + : verts; + + // Render finished (closed) lasso polygons + for (const [, poly] of lassoPolygons) { + const polyVerts = visibleWallVerts(poly.vertices); + if (!poly.isClosed || polyVerts.length < 3) continue; + const path = Skia.Path.Make(); + const v0 = n2c(polyVerts[0].x, polyVerts[0].y); + path.moveTo(v0.x, v0.y); + for (let i = 1; i < polyVerts.length; i++) { + const vt = n2c(polyVerts[i].x, polyVerts[i].y); + path.lineTo(vt.x, vt.y); + } + path.close(); + + elements.push( + + + , + ); + + // Vertex dots + for (let i = 0; i < polyVerts.length; i++) { + const vc = n2c(polyVerts[i].x, polyVerts[i].y); + const isDragging = + lassoDragVertex?.kind === 'closed' && + lassoDragVertex.polyId === poly.id && + lassoDragVertex.vertexIndex === i; + const dotR = isDragging ? 7 : 4; + const dot = Skia.Path.Make(); + const dotRect = Skia.XYWHRect( + vc.x - dotR, + vc.y - dotR, + dotR * 2, + dotR * 2, + ); + dot.addOval(dotRect); + elements.push( + , + ); + if (isDragging) { + const ring = Skia.Path.Make(); + ring.addOval(dotRect); + elements.push( + , + ); + } + } + } + + // Render current (open) polygon + if (currentLassoVertices && currentLassoVertices.length > 0) { + const openVerts = visibleWallVerts(currentLassoVertices); + if (openVerts.length > 0) { + const openPath = Skia.Path.Make(); + const v0 = n2c(openVerts[0].x, openVerts[0].y); + openPath.moveTo(v0.x, v0.y); + for (let i = 1; i < openVerts.length; i++) { + const vt = n2c(openVerts[i].x, openVerts[i].y); + openPath.lineTo(vt.x, vt.y); + } + if (openVerts.length >= 3) { + openPath.lineTo(v0.x, v0.y); + } + + elements.push( + + + , + ); + + // Vertex dots for current polygon + for (let i = 0; i < openVerts.length; i++) { + const vc = n2c(openVerts[i].x, openVerts[i].y); + const isDragging = + lassoDragVertex?.kind === 'open' && + lassoDragVertex.vertexIndex === i; + const isCloseAnchor = + i === 0 && openVerts.length >= 3 && !isDragging; + const dotR = isDragging ? 7 : isCloseAnchor ? 6 : 4; + const dot = Skia.Path.Make(); + const dotRect = Skia.XYWHRect( + vc.x - dotR, + vc.y - dotR, + dotR * 2, + dotR * 2, + ); + dot.addOval(dotRect); + elements.push( + , + ); + if (isDragging || isCloseAnchor) { + const ring = Skia.Path.Make(); + const ringR = isCloseAnchor ? dotR + 5 : dotR; + ring.addOval(Skia.XYWHRect( + vc.x - ringR, vc.y - ringR, ringR * 2, ringR * 2, + )); + elements.push( + , + ); + } + } + } + } + + return elements; + })()} @@ -1916,6 +3003,8 @@ const MaskSegmentCanvas = forwardRef { const onStartJS = () => { + if (isLassoActiveRef.current && lassoDragRef.current) { + return; + } panBaseRef.current = { ...panOffsetRef.current }; }; const onUpdateJS = (translationX: number, translationY: number) => { @@ -2089,14 +3181,357 @@ const MaskSegmentCanvas = forwardRef { + const applyVertexMove = (normX: number, normY: number) => { + const drag = lassoDragRef.current; + if (!drag) return; + + const maskData = regionMaskDataRef.current; + const wallMask = buildWallMaskSampleFromRef(maskData); + const point = wallMask + ? resolveLassoWallDragPoint( + normX, normY, wallMask, LASSO_EDGE_SNAP_SEG_PX, + ) + : { x: normX, y: normY }; + if (!point) { + return; + } + const excludePolyId = + drag.kind === 'closed' ? drag.polyId : undefined; + if (isNormPointOnAssignedWall(point.x, point.y, maskData)) { + return; + } + if ( + isNormPointInCommittedLassoArea( + point.x, + point.y, + manualWallRegionsRef.current, + lassoPolygonsRef.current, + excludePolyId, + ) + ) { + return; + } + + lassoDragMovedRef.current = true; + + if (drag.kind === 'open') { + setCurrentLassoVertices(prev => { + if (!prev || drag.vertexIndex >= prev.length) return prev; + const next = [...prev]; + next[drag.vertexIndex] = point; + currentLassoVerticesRef.current = next; + return next; + }); + return; + } + + if (drag.kind === 'closed' && drag.polyId) { + setLassoPolygons(prev => { + const poly = prev.get(drag.polyId!); + if (!poly || drag.vertexIndex >= poly.vertices.length) return prev; + const next = new Map(prev); + const verts = [...poly.vertices]; + verts[drag.vertexIndex] = point; + next.set(drag.polyId!, { ...poly, vertices: verts }); + lassoPolygonsRef.current = next; + return next; + }); + } + }; + + const performLassoTapJS = (sx: number, sy: number) => { + const cw = canvasWRef.current; + const ch = canvasHRef.current; + if (cw <= 0 || ch <= 0) return; + + const canvasCoords = screenToCanvasCoords( + sx, sy, cw, ch, + zoomScaleRef.current, panOffsetRef.current, + ); + + const imgSz = imageSizeRef2.current; + if (!imgSz) return; + + const rawNorm = canvasToNormalized( + canvasCoords.x, canvasCoords.y, cw, ch, imgSz.w, imgSz.h, + ); + if (!rawNorm) return; + + const wallMask = buildWallMaskSampleFromRef(regionMaskDataRef.current); + const maskData = regionMaskDataRef.current; + const norm = wallMask + ? snapNormPointToWallCornerOrEdge( + rawNorm.x, rawNorm.y, wallMask, LASSO_TAP_SNAP_SEG_PX, + ) + : rawNorm; + + const em = energyMapRef.current; + const isMagnetic = em != null; + + // Tap near the first vertex โ†’ close polygon + if (currentLassoVerticesRef.current && currentLassoVerticesRef.current.length >= 3) { + if ( + isNearOpenLassoFirstVertex( + canvasCoords.x, canvasCoords.y, + cw, ch, imgSz.w, imgSz.h, + currentLassoVerticesRef.current, + LASSO_CLOSE_THRESHOLD_PX, + ) + ) { + const openVerts = currentLassoVerticesRef.current; + if (!openVerts || openVerts.length < 3) { + return; + } + if ( + lassoPolygonUsesCommittedArea( + openVerts, + maskData, + manualWallRegionsRef.current, + lassoPolygonsRef.current, + ) + ) { + return; + } + const id = `lasso_${++lassoIdCounterRef.current}`; + const polygon: LassoPolygon = { + id, + vertices: [...openVerts], + isClosed: true, + }; + setLassoPolygons(prev => { + const next = new Map(prev); + next.set(id, polygon); + lassoPolygonsRef.current = next; + return next; + }); + setCurrentLassoVertices(null); + currentLassoVerticesRef.current = null; + return; + } + } + + + if ( + !canPlaceLassoPointAt( + norm.x, + norm.y, + maskData, + wallMask, + manualWallRegionsRef.current, + lassoPolygonsRef.current, + ) + ) { + return; + } + + const openVerts = currentLassoVerticesRef.current; + if (openVerts && openVerts.length > 0) { + for (let i = 0; i < openVerts.length; i++) { + if ( + canvasDistToNormVertex( + canvasCoords.x, canvasCoords.y, + openVerts[i].x, openVerts[i].y, + cw, ch, imgSz.w, imgSz.h, + ) < LASSO_VERTEX_HIT_PX + ) { + return; + } + } + const last = openVerts[openVerts.length - 1]; + if ( + canvasDistToNormVertex( + canvasCoords.x, canvasCoords.y, + last.x, last.y, + cw, ch, imgSz.w, imgSz.h, + ) < LASSO_MIN_VERTEX_SPACING_PX + ) { + return; + } + } + + // Magnetic lasso: snap path to edges between consecutive taps + if (isMagnetic) { + const prevVerts = currentLassoVerticesRef.current; + if (prevVerts && prevVerts.length > 0) { + const lastNorm = prevVerts[prevVerts.length - 1]; + const lastE = normToEnergyPoint(lastNorm.x, lastNorm.y, em); + const tapE = normToEnergyPoint(norm.x, norm.y, em); + + const rawPath = findShortestPath( + em.map, + em.w, + em.h, + lastE.x, + lastE.y, + tapE.x, + tapE.y, + em.traversable, + ); + const corners = extractCornerPoints(rawPath, 4, 1.0); + const normPoints = energyPointsToNorm(corners, em) + .filter(p => + canPlaceLassoPointAt( + p.x, p.y, maskData, wallMask, + manualWallRegionsRef.current, + lassoPolygonsRef.current, + ), + ) + .filter((p, i) => { + if (i > 0) return true; + const lastV = prevVerts[prevVerts.length - 1]; + return Math.hypot(p.x - lastV.x, p.y - lastV.y) > 0.0005; + }); + + if (normPoints.length === 0) { + return; + } + + setCurrentLassoVertices(prev => { + if (!prev) return normPoints; + const next = [...prev, ...normPoints]; + currentLassoVerticesRef.current = next; + return next; + }); + return; + } + } + + // Simple lasso: add single vertex + setCurrentLassoVertices(prev => { + const next = prev ? [...prev, norm] : [norm]; + currentLassoVerticesRef.current = next; + return next; + }); + }; + + const onBeginJS = (sx: number, sy: number) => { + lassoPendingTapRef.current = { x: sx, y: sy }; + lassoDragMovedRef.current = false; + lassoVertexCandidateRef.current = null; + + const cw = canvasWRef.current; + const ch = canvasHRef.current; + const imgSz = imageSizeRef2.current; + if (!imgSz || cw <= 0 || ch <= 0) return; + + const coords = screenToCanvasCoords( + sx, sy, cw, ch, + zoomScaleRef.current, panOffsetRef.current, + ); + + const openVerts = currentLassoVerticesRef.current; + const drawingOpen = openVerts != null && openVerts.length > 0; + + const hit = findLassoVertexHit( + coords.x, coords.y, + cw, ch, imgSz.w, imgSz.h, + LASSO_VERTEX_HIT_PX, + openVerts, + lassoPolygonsRef.current, + { openOnly: drawingOpen }, + ); + if (hit) { + lassoVertexCandidateRef.current = hit; + } + }; + + const onUpdateJS = (sx: number, sy: number) => { + if (lassoDragRef.current) { + const cw = canvasWRef.current; + const ch = canvasHRef.current; + const imgSz = imageSizeRef2.current; + if (!imgSz || cw <= 0 || ch <= 0) return; + + const coords = screenToCanvasCoords( + sx, sy, cw, ch, + zoomScaleRef.current, panOffsetRef.current, + ); + const norm = canvasToNormalized( + coords.x, coords.y, cw, ch, imgSz.w, imgSz.h, + ); + if (!norm) return; + + applyVertexMove(norm.x, norm.y); + return; + } + + const pending = lassoPendingTapRef.current; + if (!pending) return; + + const moved = Math.hypot(sx - pending.x, sy - pending.y); + if (moved > LASSO_TAP_CANCEL_PX && !lassoVertexCandidateRef.current) { + lassoPendingTapRef.current = null; + return; + } + + const candidate = lassoVertexCandidateRef.current; + if (!candidate || moved < LASSO_DRAG_ACTIVATE_PX) { + return; + } + + lassoDragRef.current = candidate; + lassoVertexCandidateRef.current = null; + lassoPendingTapRef.current = null; + setLassoDragVertex(candidate); + }; + + const onEndJS = (sx: number, sy: number) => { + if (lassoDragRef.current) { + lassoDragRef.current = null; + lassoDragMovedRef.current = false; + lassoVertexCandidateRef.current = null; + setLassoDragVertex(null); + return; + } + + lassoVertexCandidateRef.current = null; + const pending = lassoPendingTapRef.current; + lassoPendingTapRef.current = null; + if (pending) { + performLassoTapJS(pending.x, pending.y); + } + }; + + return Gesture.Pan() + .minPointers(1) + .maxPointers(1) + .minDistance(0) + .onBegin((e) => { + 'worklet'; + runOnJS(onBeginJS)(e.x, e.y); + }) + .onUpdate((e) => { + 'worklet'; + runOnJS(onUpdateJS)(e.x, e.y); + }) + .onEnd((e) => { + 'worklet'; + runOnJS(onEndJS)(e.x, e.y); + }) + .onFinalize((e) => { + 'worklet'; + runOnJS(onEndJS)(e.x, e.y); + }); + }, + [], + ); + // โ”€โ”€ Composed: pinch + pan simultaneous; tap only when neither claims โ”€โ”€โ”€ + // When lasso mode is active, swap the normal paint tap for lasso pointer. const composedGesture = useMemo( - () => - Gesture.Exclusive( + () => { + if (isLassoActive) { + return Gesture.Simultaneous(pinchGesture, lassoPointerGesture); + } + return Gesture.Exclusive( Gesture.Simultaneous(pinchGesture, panGesture), tapGesture, - ), - [], + ); + }, + [isLassoActive, tapGesture, lassoPointerGesture, pinchGesture, panGesture], ); @@ -2110,7 +3545,7 @@ const MaskSegmentCanvas = forwardRef {canvasLayoutReady ? ( - + void; swap: (showOrigin?: boolean) => void; @@ -154,6 +198,20 @@ export type MaskSegmentCanvasRef = { getPaintedRegions: () => PaintedRegionRecord[]; /** Returns the most recent auto-export or save() result, if any. */ getLastExport?: () => SavePaintResult | null; + /** Enter lasso mode โ€” user can tap wall mask area to place polygon vertices. */ + startLasso: () => void; + /** Exit lasso mode, convert all closed lasso polygons into wall-X sub-regions for painting. Returns the partition results. */ + endLasso: () => ManualWallPartition[]; + /** + * Exit the current lasso editing session without saving regions. + * Discards in-progress vertices and closed polygons from this session only; + * previously committed manual wall partitions are kept. + */ + cancelLasso: () => void; + /** Get the current manual wall partitions (only valid after endLasso has been called). */ + getManualRegions: () => ManualWallPartition[]; + /** Delete a lasso polygon by its id. Committed partitions also drop paint on that region. */ + deleteLasso: (id: string) => void; }; export type MaskSegmentCanvasProps = { diff --git a/src/index.ts b/src/index.ts index 894aaad..d41ea6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ export { default } from './components/MaskSegmentCanvas'; export type { BgrColor, InteractionConfig, + LassoPolygon, + ManualWallPartition, MaskSegmentCanvasProps, MaskSegmentCanvasRef, MaskSegmentConfig, diff --git a/src/utils/activeContour.ts b/src/utils/activeContour.ts new file mode 100644 index 0000000..f7bd917 --- /dev/null +++ b/src/utils/activeContour.ts @@ -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 = { + 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, +): 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; +} diff --git a/src/utils/magneticLasso.ts b/src/utils/magneticLasso.ts new file mode 100644 index 0000000..fd50fb6 --- /dev/null +++ b/src/utils/magneticLasso.ts @@ -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( + 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))), + })); +} diff --git a/src/utils/maskOutlinePaths.ts b/src/utils/maskOutlinePaths.ts index a73ae17..3c031b9 100644 --- a/src/utils/maskOutlinePaths.ts +++ b/src/utils/maskOutlinePaths.ts @@ -277,7 +277,7 @@ function filterOutlineLoops( return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea); } -function floodFillComponent( +export function floodFillComponent( binary: Uint8Array, cols: number, rows: number, diff --git a/src/utils/maskSegmentRuntime.ts b/src/utils/maskSegmentRuntime.ts index 301fb4e..ab1d6e7 100644 --- a/src/utils/maskSegmentRuntime.ts +++ b/src/utils/maskSegmentRuntime.ts @@ -137,6 +137,13 @@ export const DEFAULT_MASK_CONFIG: Required< splitWallsColorDistSq: 1400, splitWallsChromaBlurRadius: 5, splitWallsNeutralChromaMax: 14, + splitWallsEdgeBarrierThreshold: 160, + splitWallsCloseMaskRadius:4, + manualSplitWalls: false, + manualSplitWallsMaxCount: 8, + manualSplitWallsGapAbsorbDilatePx: 5, + magneticLasso: false, + activeContourRefine: false, }; export type ResolvedMaskSegmentRuntime = { @@ -209,6 +216,24 @@ export function mergeMaskConfig( splitWallsNeutralChromaMax: partial.splitWallsNeutralChromaMax ?? DEFAULT_MASK_CONFIG.splitWallsNeutralChromaMax, + splitWallsEdgeBarrierThreshold: + partial.splitWallsEdgeBarrierThreshold ?? + DEFAULT_MASK_CONFIG.splitWallsEdgeBarrierThreshold, + splitWallsCloseMaskRadius: + partial.splitWallsCloseMaskRadius ?? + DEFAULT_MASK_CONFIG.splitWallsCloseMaskRadius, + manualSplitWalls: + partial.manualSplitWalls ?? DEFAULT_MASK_CONFIG.manualSplitWalls, + manualSplitWallsMaxCount: + partial.manualSplitWallsMaxCount ?? + DEFAULT_MASK_CONFIG.manualSplitWallsMaxCount, + manualSplitWallsGapAbsorbDilatePx: + partial.manualSplitWallsGapAbsorbDilatePx ?? + DEFAULT_MASK_CONFIG.manualSplitWallsGapAbsorbDilatePx, + magneticLasso: + partial.magneticLasso ?? DEFAULT_MASK_CONFIG.magneticLasso, + activeContourRefine: + partial.activeContourRefine ?? DEFAULT_MASK_CONFIG.activeContourRefine, }; } diff --git a/src/utils/maskSegmentation.ts b/src/utils/maskSegmentation.ts index 82cba8a..51066bb 100644 --- a/src/utils/maskSegmentation.ts +++ b/src/utils/maskSegmentation.ts @@ -414,6 +414,10 @@ export type RegionMaskData = { cols: number; rows: number; wallSubLabels?: Uint8Array; + /** Semantic index โ†’ name table captured at segmentation time (must match labels buffer). */ + indexToName?: string[]; + /** Wall semantic index in labels buffer (captured at segmentation time). */ + wallSemanticIdx?: number; }; /** downsample mask path building (screen display does not need segmentation resolution, click still uses full resolution pickMap) */ @@ -421,7 +425,8 @@ export function downsampleMaskDataForPaths( maskData: RegionMaskData, maxLongSide: number, ): RegionMaskData { - const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData; + const { labels, baseboardBinary, cols, rows, wallSubLabels, indexToName, wallSemanticIdx } = + maskData; const longSide = Math.max(cols, rows); if (longSide <= maxLongSide) { return maskData; @@ -460,6 +465,8 @@ export function downsampleMaskDataForPaths( cols: dstCols, rows: dstRows, wallSubLabels: outWallSub, + indexToName, + wallSemanticIdx, }; } diff --git a/src/utils/wallTextureSplit.ts b/src/utils/wallTextureSplit.ts index 2a59510..acf8538 100644 --- a/src/utils/wallTextureSplit.ts +++ b/src/utils/wallTextureSplit.ts @@ -16,15 +16,88 @@ function maskCfg() { return getMaskSegmentRuntimeConfig().mask; } -function bboxToPolygon(bbox: SegmentRegion['bbox']): { x: number; y: number }[] { - return [ - { x: bbox.x, y: bbox.y }, - { x: bbox.x + bbox.w, y: bbox.y }, - { x: bbox.x + bbox.w, y: bbox.y + bbox.h }, - { x: bbox.x, y: bbox.y + bbox.h }, +type Point = { x: number; y: number }; + +/** Moore-neighbor boundary tracer on a binary mask component. */ +function traceMaskPolygon( + mask: Uint8Array, + cols: number, + rows: number, +): Point[] { + // Find first non-zero pixel (top-left) + let startX = -1, startY = -1; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + if (mask[y * cols + x]) { startX = x; startY = y; break; } + } + if (startX >= 0) break; + } + if (startX < 0) return []; + + // Moore 8-neighbor clockwise trace + const dirs: [number, number][] = [ + [1, 0], [1, -1], [0, -1], [-1, -1], + [-1, 0], [-1, 1], [0, 1], [1, 1], ]; + const path: Point[] = []; + let cx = startX, cy = startY; + let dir = 7; // start searching from up-left + + for (let i = 0; i < cols * rows; i++) { + path.push({ x: cx, y: cy }); + let found = false; + for (let j = 0; j < 8; j++) { + const d = (dir + 1 + j) % 8; // search clockwise from last direction+1 + const nx = cx + dirs[d][0]; + const ny = cy + dirs[d][1]; + if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue; + if (mask[ny * cols + nx]) { + cx = nx; cy = ny; dir = (d + 4) % 8; // face back toward previous pixel + found = true; + break; + } + } + if (!found) break; + if (path.length > 2 && cx === startX && cy === startY) break; + } + + return path; } +/** Douglas-Peucker polygon simplification (epsilon in pixels). */ +function simplifyPolygon(points: Point[], epsilon: number): Point[] { + if (points.length <= 2) return [...points]; + const keep = new Uint8Array(points.length); + keep[0] = 1; + keep[points.length - 1] = 1; + + const recurse = (s: number, e: number) => { + if (e - s <= 1) return; + const dx = points[e].x - points[s].x; + const dy = points[e].y - points[s].y; + const lenSq = dx * dx + dy * dy; + let maxDist = 0, maxIdx = s; + for (let i = s + 1; i < e; i++) { + let d: number; + if (lenSq === 0) { + d = Math.hypot(points[i].x - points[s].x, points[i].y - points[s].y); + } else { + const t = Math.max(0, Math.min(1, + ((points[i].x - points[s].x) * dx + (points[i].y - points[s].y) * dy) / lenSq, + )); + d = Math.hypot(points[i].x - (points[s].x + t * dx), points[i].y - (points[s].y + t * dy)); + } + if (d > maxDist) { maxDist = d; maxIdx = i; } + } + if (maxDist > epsilon) { keep[maxIdx] = 1; recurse(s, maxIdx); recurse(maxIdx, e); } + }; + recurse(0, points.length - 1); + + return points.filter((_, i) => keep[i]); +} + +const POLYGON_SIMPLIFY_EPSILON = 2.5; + function computeLabChromaMaps( originBgr: Uint8Array, cols: number, @@ -42,6 +115,79 @@ function computeLabChromaMaps( return { aMap, bMap }; } +/** Per-channel BGR Sobel gradient magnitude. max(B, G, R) for sensitivity to color edges. */ +function buildEdgeBarrierMask( + bgr: Uint8Array, + cols: number, + rows: number, + wallIdx: number, + labels: Uint8Array, + baseboardBinary: Uint8Array, + threshold: number, +): Uint8Array { + const n = cols * rows; + const barriers = new Uint8Array(n); + if (threshold <= 0) return barriers; + + // Per-channel Sobel 3x3 โ†’ take max raw gradient. + // Raw range is [0, ~1442] for 8โ€‘bit BGR.โ€ฏNo normalization โ€” a single + // extremely strong edge (e.g. window frame) would compress all other + // edges if we normalized relative to maxG. + const C = cols; + + for (let y = 1; y < rows - 1; y++) { + const r0 = (y - 1) * C; + const r1 = y * C; + const r2 = (y + 1) * C; + for (let x = 1; x < C - 1; x++) { + const i = r1 + x; + if (labels[i] !== wallIdx || baseboardBinary[i]) continue; + + const a0 = r0 + (x - 1), a1 = r0 + x, a2 = r0 + (x + 1); + const b0 = r1 + (x - 1), b2 = r1 + (x + 1); + const c0 = r2 + (x - 1), c1 = r2 + x, c2 = r2 + (x + 1); + + let best = 0; + for (let ch = 0; ch < 3; ch++) { + const a = bgr[a0 * 3 + ch]; + const b = bgr[a1 * 3 + ch]; + const c = bgr[a2 * 3 + ch]; + const d = bgr[b0 * 3 + ch]; + const e = bgr[b2 * 3 + ch]; + const f = bgr[c0 * 3 + ch]; + const gv = bgr[c1 * 3 + ch]; + const h = bgr[c2 * 3 + ch]; + const gx = -a + c - 2 * d + 2 * e - f + h; + const gy = -a - 2 * b - c + f + 2 * gv + h; + const mag = Math.sqrt(gx * gx + gy * gy); + if (mag > best) best = mag; + } + if (best > threshold) barriers[i] = 1; + } + } + + // Dilate 1px to widen the barrier slightly + return dilateBinary1px(barriers, cols, rows); +} + +function dilateBinary1px(src: Uint8Array, cols: number, rows: number): Uint8Array { + const dst = new Uint8Array(src); + for (let y = 1; y < rows - 1; y++) { + const rc = y * cols; + for (let x = 1; x < cols - 1; x++) { + const i = rc + x; + if (src[i]) continue; + if ( + src[i - 1] || src[i + 1] || + src[(y - 1) * cols + x] || src[(y + 1) * cols + x] + ) { + dst[i] = 1; + } + } + } + return dst; +} + function chromaMag(a: number, b: number): number { const da = a - 128; const db = b - 128; @@ -109,6 +255,81 @@ function isWallPixel( return labels[i] === wallIdx; } +/** + * Morphological close on the wall mask: dilate then erode to fill small + * non-wall holes (windows, doors, occlusions) that would otherwise + * fragment a single wall into disconnected components during BFS. + * Returns a temporary labels array with holes filled as wallIdx. + */ +function closeWallMask( + labels: Uint8Array, + baseboardBinary: Uint8Array, + wallIdx: number, + cols: number, + rows: number, + radius: number, +): { labels: Uint8Array; baseboardBinary: Uint8Array } { + const n = cols * rows; + + // Binary wall mask + const bin = new Uint8Array(n); + for (let i = 0; i < n; i++) { + if (labels[i] === wallIdx && !baseboardBinary[i]) bin[i] = 1; + } + + // Dilate N times + let dilated = bin; + for (let pass = 0; pass < radius; pass++) { + dilated = dilateBinary1px(dilated, cols, rows); + } + + // Erode N times + let closed = dilated; + for (let pass = 0; pass < radius; pass++) { + closed = erodeBinary1px(closed, cols, rows); + } + + // Build closed labels: pixels that were NOT wall but are now in the closed + // mask get wallIdx so the BFS can cross them. Original non-wall pixels + // outside the wall area are unchanged. + const closedLabels = new Uint8Array(labels); + for (let i = 0; i < n; i++) { + if (closed[i] && labels[i] !== wallIdx && !baseboardBinary[i]) { + closedLabels[i] = wallIdx; + } + } + + // These pixels were baseboard or other semantic โ€” keep them excluded + const closedBaseboard = new Uint8Array(baseboardBinary); + for (let i = 0; i < n; i++) { + if (closed[i] && labels[i] !== wallIdx && baseboardBinary[i]) { + // Baseboard inside the closed area: treat as wall so it doesn't block BFS + closedLabels[i] = wallIdx; + closedBaseboard[i] = 0; + } + } + + return { labels: closedLabels, baseboardBinary: closedBaseboard }; +} + +function erodeBinary1px(src: Uint8Array, cols: number, rows: number): Uint8Array { + const dst = new Uint8Array(src); + for (let y = 1; y < rows - 1; y++) { + const rc = y * cols; + for (let x = 1; x < cols - 1; x++) { + const i = rc + x; + if (!src[i]) continue; + if ( + !src[rc + (x - 1)] || !src[rc + (x + 1)] || + !src[(y - 1) * cols + x] || !src[(y + 1) * cols + x] + ) { + dst[i] = 0; + } + } + } + return dst; +} + /** * 4-connected component growth: compares against component chroma mean to avoid chain bridging; * forces separation at neutral/colored wall boundaries. @@ -119,6 +340,7 @@ function labelWallComponents( wallIdx: number, aMap: Uint8Array, bMap: Uint8Array, + barrierMask: Uint8Array, cols: number, rows: number, distSqThreshold: number, @@ -164,6 +386,7 @@ function labelWallComponents( const nx = ni % cols; if (Math.abs(nx - cx) > 1) continue; if (!isWallPixel(labels, baseboardBinary, wallIdx, ni)) continue; + if (barrierMask[ni]) continue; if (compLabels[ni] >= 0) continue; const na = aMap[ni]; @@ -379,6 +602,25 @@ function mergeSmallComponents( } } + // Second pass: any component that is smaller than its most-adjacent + // neighbor is almost certainly a barrier artefact โ€” merge it. + for (let c = 0; c < compCount; c++) { + if (stats[c].area <= 0) continue; + const neighbors = adjacency.get(c); + if (!neighbors || neighbors.size === 0) continue; + let bestNeighbor = -1; + let bestBorder = 0; + for (const [nb, border] of neighbors) { + if (border > bestBorder) { + bestBorder = border; + bestNeighbor = nb; + } + } + if (bestNeighbor < 0) continue; + if (stats[c].area >= stats[bestNeighbor].area) continue; + union(c, bestNeighbor); + } + const pixelCount = cols * rows; for (let i = 0; i < pixelCount; i++) { const c = compLabels[i]; @@ -387,6 +629,78 @@ function mergeSmallComponents( } } +/** + * Fresh-adjacency pass: rebuild the adjacency graph from current labels and + * merge every component below minArea into its most-adjacent larger neighbor. + * Runs until all tiny fragments are absorbed or no more merges possible. + */ +function mergeFreshTinyComponents( + compLabels: Int32Array, + stats: WallComponent[], + cols: number, + rows: number, + minArea: number, +): void { + const compCount = stats.length; + // Build adjacency from current labels + const adjacency = new Map>(); + const addEdge = (a: number, b: number) => { + if (a === b) return; + let m = adjacency.get(a); + if (!m) { m = new Map(); adjacency.set(a, m); } + m.set(b, (m.get(b) ?? 0) + 1); + }; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const i = y * cols + x; + const a = compLabels[i]; + if (a < 0) continue; + if (x + 1 < cols) { const b = compLabels[i + 1]; if (b >= 0) addEdge(a, b); } + if (y + 1 < rows) { const b = compLabels[i + cols]; if (b >= 0) addEdge(a, b); } + } + } + + const remap = new Int32Array(compCount); + for (let i = 0; i < compCount; i++) remap[i] = i; + const find = (x: number): number => { + while (remap[x] !== x) { remap[x] = remap[remap[x]]; x = remap[x]; } + return x; + }; + const union = (a: number, b: number) => { + const ra = find(a), rb = find(b); + if (ra === rb) return; + if (stats[ra].area >= stats[rb].area) { + remap[rb] = ra; stats[ra].area += stats[rb].area; stats[rb].area = 0; + } else { + remap[ra] = rb; stats[rb].area += stats[ra].area; stats[ra].area = 0; + } + }; + + for (let iter = 0; iter < compCount; iter++) { + let changed = false; + for (let c = 0; c < compCount; c++) { + if (stats[c].area <= 0 || stats[c].area >= minArea) continue; + const nbrs = adjacency.get(c); + if (!nbrs || nbrs.size === 0) continue; + let bestNb = -1, bestBorder = 0; + for (const [nb, border] of nbrs) { + if (remap[nb] !== nb) continue; + if (border > bestBorder) { bestBorder = border; bestNb = nb; } + } + if (bestNb < 0) continue; + union(c, bestNb); + changed = true; + } + if (!changed) break; + } + + const n = cols * rows; + for (let i = 0; i < n; i++) { + const c = compLabels[i]; + if (c >= 0) compLabels[i] = find(c); + } +} + function relabelComponentsContiguous( compLabels: Int32Array, cols: number, @@ -413,7 +727,7 @@ function relabelComponentsContiguous( return { labels: out, compCount, stats }; } -function buildPickMapAfterWallSplit( +export function buildPickMapAfterWallSplit( labels: Uint8Array, baseboardBinary: Uint8Array, wallIdx: number, @@ -437,12 +751,15 @@ function buildPickMapAfterWallSplit( continue; } - if (labels[i] === wallIdx && wallSubLabels[i] !== WALL_SUB_LABEL_NONE) { - const wallName = `wall-${wallSubLabels[i] + 1}`; - const regionId = nameToId.get(wallName); - if (regionId !== undefined) { - pick[i] = regionId + 1; + if (wallIdx >= 0 && labels[i] === wallIdx) { + if (wallSubLabels[i] !== WALL_SUB_LABEL_NONE) { + const wallName = `wall-${wallSubLabels[i] + 1}`; + const regionId = nameToId.get(wallName); + if (regionId !== undefined) { + pick[i] = regionId + 1; + } } + // Unpartitioned wall pixels stay 0 (no parent "wall" region after manual split). continue; } @@ -459,7 +776,50 @@ function buildPickMapAfterWallSplit( return pick; } -function dilatePickBuffer1px( +/** + * Manual lasso split: copy the existing pick map and rewrite wall pixels only. + * Non-wall pick codes stay identical so prior paints and hit-testing remain stable. + */ +export function patchPickMapForManualWallSplit( + existingPick: Uint8Array, + labels: Uint8Array, + baseboardBinary: Uint8Array, + wallIdx: number, + wallSubLabels: Uint8Array, + nameToId: Map, + cols: number, + rows: number, +): Uint8Array { + const pixelCount = cols * rows; + const pick = new Uint8Array(existingPick); + + if (wallIdx < 0) { + return pick; + } + + for (let i = 0; i < pixelCount; i++) { + if (baseboardBinary[i]) { + continue; + } + if (labels[i] !== wallIdx) { + continue; + } + + const sub = wallSubLabels[i]; + if (sub === WALL_SUB_LABEL_NONE) { + pick[i] = 0; + continue; + } + + const wallName = `wall-${sub + 1}`; + const regionId = nameToId.get(wallName); + pick[i] = regionId !== undefined ? regionId + 1 : 0; + } + + return pick; +} + +export function dilatePickBuffer1px( pick: Uint8Array, cols: number, rows: number, @@ -505,6 +865,130 @@ function dilatePickBuffer1px( return dst; } +const GAP_ABSORB_NEIGHBOURS: [number, number][] = [ + [-1, -1], [0, -1], [1, -1], + [-1, 0], /* */ [1, 0], + [-1, 1], [0, 1], [1, 1], +]; + +export type LassoPolyBBox = { x: number; y: number; w: number; h: number }; + +function expandLassoPolyBBox(b: LassoPolyBBox, x: number, y: number): void { + if (b.w === 0 && b.h === 0) { + b.x = x; + b.y = y; + b.w = 1; + b.h = 1; + return; + } + const right = b.x + b.w; + const bottom = b.y + b.h; + if (x < b.x) { + b.w = right - x; + b.x = x; + } else if (x + 1 > right) { + b.w = x + 1 - b.x; + } + if (y < b.y) { + b.h = bottom - y; + b.y = y; + } else if (y + 1 > bottom) { + b.h = y + 1 - b.y; + } +} + +function recomputeLassoPolyStats( + polyLabels: Uint8Array, + polyCount: number, + cols: number, + rows: number, + areas: number[], + bboxes: LassoPolyBBox[], +): void { + areas.fill(0); + for (let pi = 0; pi < polyCount; pi++) { + bboxes[pi] = { x: cols, y: rows, w: 0, h: 0 }; + } + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const i = y * cols + x; + const pi = polyLabels[i]; + if (pi === WALL_SUB_LABEL_NONE || pi >= polyCount) { + continue; + } + areas[pi]++; + expandLassoPolyBBox(bboxes[pi], x, y); + } + } +} + +/** + * Morphologically dilate each lasso polygon into adjacent unassigned wall pixels + * (up to `dilateRadius` seg pixels) so thin gaps against the wall mask merge in. + */ +export function absorbSmallWallGapsForLassoPolygons( + polyLabels: Uint8Array, + polyCount: number, + areas: number[], + bboxes: LassoPolyBBox[], + labels: Uint8Array, + baseboardBinary: Uint8Array, + wallSemanticIdx: number, + priorAssignedLabels: Uint8Array, + cols: number, + rows: number, + dilateRadius: number, +): void { + if ( + polyCount <= 0 || + dilateRadius <= 0 || + wallSemanticIdx < 0 + ) { + return; + } + + const isExpandable = (i: number): boolean => { + if (labels[i] !== wallSemanticIdx) return false; + if (baseboardBinary[i]) return false; + if (priorAssignedLabels[i] !== WALL_SUB_LABEL_NONE) return false; + return polyLabels[i] === WALL_SUB_LABEL_NONE; + }; + + for (let polyIdx = 0; polyIdx < polyCount; polyIdx++) { + for (let pass = 0; pass < dilateRadius; pass++) { + const toAdd: number[] = []; + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const i = y * cols + x; + if (polyLabels[i] !== polyIdx) continue; + + for (const [dx, dy] of GAP_ABSORB_NEIGHBOURS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue; + const ni = ny * cols + nx; + if (isExpandable(ni)) { + toAdd.push(ni); + } + } + } + } + + if (toAdd.length === 0) { + break; + } + + for (const ni of toAdd) { + polyLabels[ni] = polyIdx; + } + } + } + + recomputeLassoPolyStats(polyLabels, polyCount, cols, rows, areas, bboxes); +} + /** * After semantic segmentation, subdivide the wall region into wall-1, wall-2โ€ฆ by source image texture features */ @@ -536,7 +1020,20 @@ export function splitWallRegionsByTexture( return result; } + // Close small mask holes so non-wall pixels (windows, doors) don't + // fragment a single wall into disconnected BFS components. + const closeRadius = cfg.splitWallsCloseMaskRadius ?? 3; + const closed = closeRadius > 0 + ? closeWallMask(labels, baseboardBinary, wallIdx, cols, rows, closeRadius) + : { labels, baseboardBinary }; + const bfsLabels = closed.labels; + const bfsBaseboard = closed.baseboardBinary; + const { aMap: rawA, bMap: rawB } = computeLabChromaMaps(originBgr, cols, rows); + const barrierMask = buildEdgeBarrierMask( + originBgr, cols, rows, wallIdx, bfsLabels, bfsBaseboard, + cfg.splitWallsEdgeBarrierThreshold ?? 36, + ); const distSqThreshold = cfg.splitWallsColorDistSq; const neutralChromaMax = cfg.splitWallsNeutralChromaMax; const minAreaFloor = Math.max( @@ -545,11 +1042,12 @@ export function splitWallRegionsByTexture( ); const { compLabels: rawCompLabels, compCount: rawCount } = labelWallComponents( - labels, - baseboardBinary, + bfsLabels, + bfsBaseboard, wallIdx, rawA, rawB, + barrierMask, cols, rows, distSqThreshold, @@ -573,8 +1071,18 @@ export function splitWallRegionsByTexture( neutralChromaMax, ); - const { labels: finalCompLabels, compCount, stats: finalStats } = - relabelComponentsContiguous(rawCompLabels, cols, rows); + let finalCompLabels: Int32Array; + let compCount: number; + let finalStats: WallComponent[]; + + { + const relabeled = relabelComponentsContiguous(rawCompLabels, cols, rows); + mergeFreshTinyComponents(relabeled.labels, relabeled.stats, cols, rows, minAreaFloor); + const final = relabelComponentsContiguous(relabeled.labels, cols, rows); + finalCompLabels = final.labels; + compCount = final.compCount; + finalStats = final.stats; + } if (compCount === 0) { return result; @@ -607,18 +1115,37 @@ export function splitWallRegionsByTexture( const wallHex = wallRef?.hex ?? wallRegion.hex; const wallColor = wallRef?.bgr ?? wallRegion.color; + // Build per-component binary masks and trace simplified polygons + const compMasks = new Array(ranked.length); + for (let i = 0; i < pixelCount; i++) { + const c = finalCompLabels[i]; + if (c < 0) continue; + const rank = rankMap.get(c); + if (rank === undefined) continue; + if (!compMasks[rank]) compMasks[rank] = new Uint8Array(pixelCount); + compMasks[rank][i] = 1; + } + const nonWallRegions = regions.filter(reg => reg.name !== 'wall'); const wallSubRegions: SegmentRegion[] = ranked.map((s, rank) => { - const bbox = s.bbox; - const poly = bboxToPolygon(bbox); + const mask = compMasks[rank]; + const rawPoly = mask ? traceMaskPolygon(mask, cols, rows) : []; + const poly = simplifyPolygon(rawPoly, POLYGON_SIMPLIFY_EPSILON); + // Fallback to bbox if contour tracing failed + const fallback = poly.length >= 3 ? poly : [ + { x: s.bbox.x, y: s.bbox.y }, + { x: s.bbox.x + s.bbox.w, y: s.bbox.y }, + { x: s.bbox.x + s.bbox.w, y: s.bbox.y + s.bbox.h }, + { x: s.bbox.x, y: s.bbox.y + s.bbox.h }, + ]; return { id: 0, name: `wall-${rank + 1}`, hex: wallHex, color: { ...wallColor }, - polygons: [poly], - outlinePolygons: [poly], - bbox, + polygons: [fallback], + outlinePolygons: [fallback], + bbox: s.bbox, area: s.area, }; });