[**🇨🇳 中文**](README.zh-CN.md) --- # 🎨 react-native-mask-segment-canvas A React Native **0.79** interactive mask segmentation library. The core export is the `MaskSegmentCanvas` component, consumable via **npm package** or **npm link** from any React Native project. - 🧠 **OpenCV** (`react-native-fast-opencv`): mask semantic layout, baseboard patching, region extraction - 🖌️ **Skia RuntimeEffect (SkSL)**: single-pass full-screen shader blending original image + LAB low/high frequency texture color overlays - ✂️ **Skia Path**: dashed outline highlights for regions - 👆 **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`). --- ## Table of Contents - [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) --- ## 🔭 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): ```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 ``` ### 🛠️ Postinstall Setup This library relies on `patch-package` to patch `react-native-fast-opencv`. Your host `package.json` must include: ```json { "scripts": { "postinstall": "patch-package" }, "devDependencies": { "patch-package": "^8.0.1" } } ``` After installing this library, patches from `node_modules/react-native-mask-segment-canvas/patches/` are applied automatically during the host's `postinstall`. ### 📱 iOS / Android Native Dependencies ```bash cd ios && pod install && cd .. ``` Ensure the host project has completed Skia, Reanimated, and OpenCV native setup per each library's documentation. ### 🚇 Metro Configuration When using `npm link`, a monorepo, or `file:` dependencies, add this library to `watchFolders` and use `extraNodeModules` + `blockList` to prevent duplicate module resolution: ```js const path = require('path'); module.exports = { watchFolders: [path.resolve(__dirname, '../MaskSegmentApp')], resolver: { nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], extraNodeModules: { 'react-native-reanimated': path.resolve(__dirname, 'node_modules/react-native-reanimated'), '@shopify/react-native-skia': path.resolve(__dirname, 'node_modules/@shopify/react-native-skia'), 'react-native-gesture-handler': path.resolve(__dirname, 'node_modules/react-native-gesture-handler'), 'react-native-fast-opencv': path.resolve(__dirname, 'node_modules/react-native-fast-opencv'), 'react-native-safe-area-context': path.resolve(__dirname, 'node_modules/react-native-safe-area-context'), 'react-native-fs': path.resolve(__dirname, 'node_modules/react-native-fs'), }, blockList: [ /\/MaskSegmentApp\/node_modules\/@shopify\/react-native-skia\//, /\/MaskSegmentApp\/node_modules\/react-native-reanimated\//, /\/MaskSegmentApp\/node_modules\/react-native-fast-opencv\//, /\/MaskSegmentApp\/node_modules\/react-native-gesture-handler\//, /\/MaskSegmentApp\/node_modules\/react-native-safe-area-context\//, /\/MaskSegmentApp\/node_modules\/react-native-fs\//, ], }, }; ``` **Strongly recommended** — add this at the very top of the host `index.js` (before any business code): ```js import '@shopify/react-native-skia'; ``` See `example/metro.config.js` and `example/index.js` for the complete configuration with all peer singleton packages. ### ⚠️ Troubleshooting: Duplicate Module Errors Common symptoms: - `SkiaPictureView must be a function (received 'undefined')` - `createAnimatedNode: Animated node[...] already exists` These are almost always caused by Metro resolving multiple copies of reanimated / skia / gesture-handler / fast-opencv / safe-area packages. **Best practice:** 1. Copy the `singletonPackages` + `extraNodeModules` + `blockList` pattern from `example/metro.config.js` 2. At the top of your `index.js`, import gesture-handler → reanimated → skia in order 3. Restart Metro with `--reset-cache` and reinstall the app See `example/README.md` for a detailed checklist and template. --- ## 🚀 Quick Start (Dev Demo) The root `App.tsx` is a full self-test demo that imports directly from `./src`. ```bash cd MaskSegmentApp npm install cd ios && bundle exec pod install && cd .. npm start # In another terminal npm run ios # or npm run android ``` **To see how a consumer project integrates:** go to the `example/` directory and follow its `README.md`. It uses `import from 'react-native-mask-segment-canvas'` with standard `package.json` and Metro config, fully simulating a consumer environment. --- ## 💡 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 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. --- ## 🧩 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 } }, // ... ]; ``` --- ## 📁 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/ ``` --- ## 📚 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