react-native-mask-segment-c.../README.md
a1518 f7fd70005d docs: add bilingual README (EN default, ZH-CN via switcher)
Split README into README.md (English) and README.zh-CN.md (Chinese)
with cross-links at the top of each file. All heading anchors in the
Chinese version use explicit <a id="..."> tags for reliable TOC
navigation on GitHub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 22:50:32 -07:00

990 lines
50 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[**🇨🇳 中文**](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<MaskSegmentCanvasRef>(null);
const [imagePaths, setImagePaths] = useState<ImagePaths | null>(null);
const [pathsError, setPathsError] = useState('');
const [watchState, setWatchState] = useState<MaskSegmentWatchState | ''>('');
const [errorMessage, setErrorMessage] = useState('');
const [sessionDraft] = useState<MaskSegmentSession | null>(null);
const isInteractive = INTERACTIVE_STATES.includes(
watchState as MaskSegmentWatchState,
);
const isOutlineReady = watchState === 'mask_paths_ready';
const isCanvasLoading =
imagePaths != null &&
watchState !== '' &&
!INTERACTIVE_STATES.includes(watchState as MaskSegmentWatchState) &&
watchState !== 'error';
// 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 <Text>{pathsError}</Text>;
}
if (!imagePaths) {
return (
<View>
<ActivityIndicator />
<Text>Waiting for origin and mask images...</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
{isCanvasLoading ? <Text>Loading: {watchState}</Text> : null}
{watchState === 'interactive' ? (
<Text>Paintable (outlines loading...)</Text>
) : null}
{isOutlineReady ? <Text>Ready</Text> : null}
{errorMessage ? <Text>{errorMessage}</Text> : null}
<MaskSegmentCanvas
ref={canvasRef}
style={{ flex: 1 }}
originUrl={imagePaths.origin}
maskUrl={imagePaths.mask}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="rgba(20, 120, 235, 0.58)"
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
pipelineConfig={{ maxImageLongSide: 720 }}
paintConfig={{ colorBaseOpacity: 0.88 }}
interactionConfig={{
initRegionFlashMs: 1000,
enableInitRegionFlash: true,
}}
initialSession={sessionDraft ?? undefined}
showDebugPickers={false}
showToolbar={false}
showColorBar
showStatusRow={false}
showOverlayButtons
disabled={!isInteractive}
onWatch={(state, durationMs, detail) => {
setWatchState(state);
console.log('[onWatch]', state, durationMs, detail);
}}
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
toast(payload.hint);
return;
}
console.log('painted', payload.regionId, payload.regionName, payload.color, payload.configJson);
}}
onError={message => {
setErrorMessage(message);
setWatchState('error');
}}
/>
</View>
);
}
```
### 📊 State Variables
| State | Type | Purpose |
| ----------------- | ---------------------------- | ------------------------------------------------------------------------ |
| `imagePaths` | `{ origin, mask } | null` | Local/remote image paths resolved by the host |
| `pathsError` | `string` | Error message when path resolution or PNG pre-warming fails |
| `watchState` | `MaskSegmentWatchState | ''` | Initialization stage reported by `onWatch` |
| `isInteractive` | derived | `true` when `interactive` or `mask_paths_ready` — operations are allowed |
| `isOutlineReady` | derived | `true` when `mask_paths_ready` — carousel dashed outlines are ready |
| `isCanvasLoading` | derived | Canvas init is blocking (not including PNG path waiting) |
| `errorMessage` | `string` | Segmentation/loading failure message written by `onError` |
| `sessionDraft` | `MaskSegmentSession | null` | Draft restored from MMKV or similar storage |
### ⚙️ Choosing Configuration Values
| Config | Use top-level prop when... | Use nested Config when... |
| ----------------------------- | ----------------------------------------- | ----------------------------------------------------------------------- |
| Semantic colors | `semanticColors={...}` for most cases | `maskConfig.semanticColors` when paired with other mask params |
| Outline color | `regionOutlineColor="..."` for most cases | `paintConfig.regionOverlayFill` when also customizing the brush palette |
| Black threshold, max regions | — | `maskConfig` |
| Image processing size | — | `pipelineConfig` |
| Flash interval, tap tolerance | — | `interactionConfig` |
Top-level props and nested Configs **can coexist**; top-level `semanticColors` / `regionOutlineColor` take priority.
### 🔄 watchState & UI Guidance
```ts
// Blocking loading (before regions + paint layers are ready)
const isLoading = ![
'interactive',
'mask_paths_ready',
'error',
'',
].includes(watchState);
// Allow tapping regions, selecting colors, painting (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<string, unknown>` | 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<string, unknown>; // from setPaintColor / initialPaintConfigJson
}
// Tapped a valid region without selecting a brush (no paint performed)
{
kind: 'brush_required';
hint: string; // e.g. "Please select a brush color first (bottom color bar or ref.setPaintColor)"
regionId: number;
regionName: string;
}
```
Example:
```tsx
onPaintCallback={payload => {
if (payload.kind === 'brush_required') {
showToast(payload.hint);
return;
}
savePaintRecord(payload.regionId, payload.color, payload.configJson);
}}
```
`onWatch` `detail` (`MaskSegmentWatchDetail`):
| Field | Type | Description |
| ----------------- | --------- | ------------------------------------------- |
| `regionCount` | `number` | Current effective region count |
| `maskPathsReady` | `boolean` | Whether outline Skia paths are ready |
| `freqLayersReady` | `boolean` | Whether frequency Shader textures are ready |
| `errorMessage` | `string` | Failure description in `error` state |
#### onWatch State Flow
```
init
→ images_loaded Origin + mask read complete
→ mask_aligned Mask dimensions aligned
→ mask_sampled Mask pixel sampling complete
→ regions_ready Region extraction succeeded
→ layers_ready Paint texture layers ready (detail.maskPathsReady may still be false)
→ interactive Interactive (can tap regions, select colors, paint)
→ mask_paths_ready Outline paths ready (carousel dashed outlines can display; detail.maskPathsReady is true)
→ error Failure (detail.errorMessage has description)
```
`layers_ready` / `interactive` may fire before outline paths finish computing. If the host dismisses a blocking loader at `interactive`, the user can already operate; carousel dashed outlines appear automatically after `mask_paths_ready`.
### 🔧 Ref Methods
Accessed via `ref` (type `MaskSegmentCanvasRef`):
| Method | Signature | Description |
| ------------------- | ---------------------------------------- | --------------------------------------------------------------------------- |
| `reset` | `() => void` | Undo last paint step (by `paintHistory`) |
| `swap` | `(showOrigin?: boolean) => void` | Toggle origin image comparison; omit arg to toggle, `true`/`false` to force |
| `save` | `(options?) => Promise<SavePaintResult>` | Composite and save PNG; `options.destDir` optional output directory |
| `session` | `() => MaskSegmentSession` | Export JSON-serializable session (for MMKV storage) |
| `loadSession` | `(session) => void` | Restore paint state (also available via `initialSession`) |
| `setPaintColor` | `(color, configJson?) => void` | Set current brush color; clears bottom color bar selection |
| `setMaskConfig` | `(config) => void` | Update mask config at runtime and **re-segment** |
| `clearAllPaint` | `() => void` | Clear all paint records |
| `resegment` | `() => Promise<void>` | Clear PNG cache and re-segment |
| `getRegions` | `() => SegmentRegion[]` | Snapshot of current region list |
| `getPaintedRegions` | `() => PaintedRegionRecord[]` | Snapshot of current paint records |
`SavePaintResult`: `{ filePath, width, height, paintedCount, previewPath? }`
Code examples:
```tsx
const ref = useRef<MaskSegmentCanvasRef>(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<string, unknown>;
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
<MaskSegmentCanvas
originUrl={localOriginPath}
maskUrl={localMaskPath}
showDebugPickers={false}
showToolbar={false}
semanticColors={MASK_SEMANTIC_COLORS}
regionOutlineColor="#1e96ff"
onWatch={(state, ms, detail) => {
if (state === 'interactive') hideBlockingLoader();
if (state === 'mask_paths_ready') hideOutlineHint();
}}
/>
```
### 💾 Draft Recovery
```tsx
const draft = JSON.parse(mmkv.getString('paint_draft'));
<MaskSegmentCanvas
originUrl={draft.originUrl}
maskUrl={draft.maskUrl}
initialSession={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 } },
// ...
];
<MaskSegmentCanvas
semanticColors={gymColors}
maskConfig={{ blackThreshold: 30, maxRegionColors: 6 }}
/>
```
---
## 📁 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`** | **~320450ms** | Can tap regions, select colors, Shader paint |
| Outlines ready | `mask_paths_ready` | ~430550ms | ~100ms after `interactive`; carousel outlines can display |
`interactive` does **not wait** for outline paths; `mask_paths_ready` only affects the initial carousel and optional UI hints.
Same-image sub-step magnitudes (`__DEV__` logs, default pipeline):
| Sub-step | Approx. Duration | Working Resolution |
| ---------------------------------------- | ---------------- | ------------------------------ |
| OpenCV LAB high/low freq | ~1040ms | 270×480 |
| High/low freq Skia textures | ~2030ms | same |
| Layout scan + baseboard + pick table | ~90120ms | 405×720 (1080p → longSide 720) |
| Full contour paths (async, non-blocking) | ~80150ms | 270×480 |
### 📐 Resolution vs pipelineConfig
Compute-intensive steps are capped by **maximum long side limits** and do **not scale linearly with 4K/8K origin images**. **Full PNG decoding** still scales linearly with pixel count.
| Step | Config Key | 1080×1920 Actual Size | Scales with Origin Pixels |
| ------------------------ | --------------------------- | --------------------- | ------------------------------------- |
| PNG decode | — | 1080×1920 × 2 images | **Yes** |
| Mask seg / pickMap | `maxImageLongSide: 720` | ~405×720 | **No** (fixed when long side >720) |
| Shader high/low freq | `paintFreqMaxLongSide: 480` | ~270×480 | **No** |
| Working area Skia origin | same as `maxImageLongSide` | ~405×720 | **No** |
| Dashed outlines | `maskPathMaxLongSide: 480` | ~270×480 | **No** (does not block `interactive`) |
### ⏱️ interactive Estimation (Default Pipeline)
| Origin Spec | Relative to 1080p Pixels | With PNG Pre-warm | Cold Start (no pre-warm) |
| -------------- | ------------------------ | ----------------- | ------------------------ |
| 1080×1920 | 1× | **320450ms** | **450700ms** |
| 1440×2560 (2K) | ~1.8× | **400550ms** | **600900ms** |
| 3840×2160 (4K) | ~4× | **500750ms** | **8001200ms** |
| 7680×4320 (8K) | ~16× | **0.81.5s** | **1.53s+** |
> **<300ms interactive**: achievable on 1080p + pre-warm + default pipeline + high-end devices, but **optimistic** — do not treat as an all-device SLA.
### 📱 Device Tier (1080p, Default Pipeline)
Relative to the ~320ms dev-environment baseline:
| Tier | Relative Multiplier | Pre-warm `interactive` | Cold Start |
| ----------------------------------- | ------------------- | ---------------------- | ---------- |
| Flagship iOS / new flagship Android | 0.81.2× | 300450ms | 500800ms |
| Mid-range Android | 1.52.5× | 500800ms | 700ms1.2s |
| Low-end Android (4GB, old SoC) | 2.54× | 800ms1.3s | 12s+ |
Android overhead primarily comes from: JS ↔ OpenCV bridge, memory bandwidth/GC, Skia texture upload.
### 📈 Impact of Raising maxImageLongSide
Setting `pipelineConfig.maxImageLongSide` to **1280** (above the default 720) results in a segmentation working area of ~720×1280, roughly **3×** the pixel count of the 720 tier:
| Scenario | Default 720 | Raised to 1280 |
| ------------------------------- | ----------- | -------------- |
| 1080p `interactive` (mid-range) | ~320800ms | **500ms1s+** |
| Segmentation / pickMap duration | ~90120ms | ~250350ms |
Higher precision for longer init time. To stay **<500ms interactive**, keep the default **720**; reduce to **640** if needed.
### 💨 Optimization Tips
1. 🚀 **PNG pre-warming (recommended)**: Call `prewarmPngBgrCacheAsync` after download/extraction and before navigating to the paint screen. Typically saves **100250ms** (greatest benefit on low-end devices).
```tsx
import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
await prewarmPngBgrCacheAsync([originPath, maskPath]);
// Then mount MaskSegmentCanvas
```
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.81.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