diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..9c68c63
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,34 @@
+# Demo app
+example/
+App.tsx
+index.js
+app.json
+metro.config.js
+babel.config.js
+
+# Tests
+__tests__/
+coverage/
+
+# Native demo projects
+ios/
+android/
+
+# Config files (not needed in published package)
+tsconfig.json
+tsconfig.build.json
+.eslintrc.js
+.prettierrc.js
+
+# Development
+.cursor/
+agent-transcripts/
+assets/
+
+# Dependencies
+node_modules/
+package-lock.json
+
+# OS
+.DS_Store
+*.log
diff --git a/App.tsx b/App.tsx
index 7d8838a..6f1452c 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,130 +1,220 @@
/**
- * Sample React Native App
- * https://github.com/facebook/react-native
+ * Mask Segment Demo App
+ * 基于 OpenCV + Skia 的掩码分区交互画布
*
- * @format
+ * 注意:这是库开发自测用的 Demo,直接引用 ./src。
+ * 业务项目集成演示请参考 example/ 目录(使用公开包名导入)。
*/
-import React from 'react';
-import type {PropsWithChildren} from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import {
- ScrollView,
+ ActivityIndicator,
StatusBar,
StyleSheet,
Text,
- useColorScheme,
View,
} from 'react-native';
+import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
+import MaskSegmentCanvas, {
+ type MaskSegmentCanvasRef,
+ type MaskSegmentSession,
+ type MaskSegmentWatchState,
+ MASK_SEMANTIC_COLORS,
+} from './src';
+import { resolveAssetPath } from './src/utils/resolveAssetPath';
+import { prewarmPngBgrCacheAsync } from './src/utils/pngImage';
-import {
- Colors,
- DebugInstructions,
- Header,
- LearnMoreLinks,
- ReloadInstructions,
-} from 'react-native/Libraries/NewAppScreen';
+const TEST_ORIGIN = require('./assets/test/origin.png');
+const TEST_MASK = require('./assets/test/mask.png');
-type SectionProps = PropsWithChildren<{
- title: string;
-}>;
+const INTERACTIVE_WATCH_STATES: MaskSegmentWatchState[] = [
+ 'interactive',
+ 'mask_paths_ready',
+];
-function Section({children, title}: SectionProps): React.JSX.Element {
- const isDarkMode = useColorScheme() === 'dark';
- return (
-
-
- {title}
-
-
- {children}
-
-
- );
+function formatWatchStatus(state: MaskSegmentWatchState | ''): string {
+ if (!state) {
+ return '';
+ }
+ if (state === 'interactive') {
+ return '可上色(轮廓加载中…)';
+ }
+ if (state === 'mask_paths_ready') {
+ return '就绪';
+ }
+ if (state === 'error') {
+ return '失败';
+ }
+ return `加载中:${state}`;
}
function App(): React.JSX.Element {
- const isDarkMode = useColorScheme() === 'dark';
+ const canvasRef = useRef(null);
+ const [testPaths, setTestPaths] = useState<{
+ origin: string;
+ mask: string;
+ } | null>(null);
+ const [loadError, setLoadError] = useState('');
+ const [watchState, setWatchState] = useState('');
+ const [sessionDraft, setSessionDraft] = useState(
+ null,
+ );
+ const isFullyReady = watchState === 'mask_paths_ready';
+ const isInitLoading =
+ testPaths != null &&
+ watchState !== '' &&
+ !INTERACTIVE_WATCH_STATES.includes(watchState as MaskSegmentWatchState) &&
+ watchState !== 'error';
- const backgroundStyle = {
- backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
- };
+ useEffect(() => {
+ let cancelled = false;
- /*
- * To keep the template simple and small we're adding padding to prevent view
- * from rendering under the System UI.
- * For bigger apps the recommendation is to use `react-native-safe-area-context`:
- * https://github.com/AppAndFlow/react-native-safe-area-context
- *
- * You can read more about it here:
- * https://github.com/react-native-community/discussions-and-proposals/discussions/827
- */
- const safePadding = '5%';
+ (async () => {
+ try {
+ const [origin, mask] = await Promise.all([
+ resolveAssetPath(TEST_ORIGIN, 'gym_test_origin.png'),
+ resolveAssetPath(TEST_MASK, 'gym_test_mask.png'),
+ ]);
+ await prewarmPngBgrCacheAsync([origin, mask]);
+ if (!cancelled) {
+ setTestPaths({ origin, mask });
+ }
+ } catch (e) {
+ if (!cancelled) {
+ setLoadError(e instanceof Error ? e.message : String(e));
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
return (
-
-
-
-
-
-
-
-
- Edit App.tsx to change this
- screen and then come back to see your edits.
-
-
-
-
- Read the docs to discover what to do next:
-
-
-
-
-
+
+
+
+ {loadError ? (
+
+ {loadError}
+
+ ) : testPaths ? (
+ <>
+ {watchState ? (
+
+ 状态: {formatWatchStatus(watchState)}
+ {isFullyReady ? ' · 轮播虚线已就绪' : null}
+
+ ) : null}
+
+ {
+ setWatchState(state);
+ if (__DEV__) {
+ const extra =
+ detail && Object.keys(detail).length > 0
+ ? ` ${JSON.stringify(detail)}`
+ : '';
+ console.log(
+ `[Demo onWatch] ${state} ${durationMs.toFixed(0)}ms${extra}`,
+ );
+ }
+ }}
+ onPaintCallback={payload => {
+ if (__DEV__) {
+ if (payload.kind === 'brush_required') {
+ console.log('[Demo onPaint]', payload.hint, payload.regionName);
+ } else {
+ console.log('[Demo onPaint]', payload.regionName, payload.color);
+ }
+ }
+ }}
+ onError={message => {
+ setLoadError(message);
+ setWatchState('error');
+ }}
+ />
+ {isInitLoading ? (
+
+
+
+ {formatWatchStatus(watchState)}
+
+
+ ) : null}
+
+ {sessionDraft ? (
+
+ 已恢复 MMKV 草稿({sessionDraft.painted.length} 区域)
+
+ ) : null}
+ >
+ ) : (
+
+
+ 加载健身房测试图…
+
+ )}
+
+
);
}
const styles = StyleSheet.create({
- sectionContainer: {
- marginTop: 32,
- paddingHorizontal: 24,
+ root: {
+ flex: 1,
+ backgroundColor: '#fff',
},
- sectionTitle: {
- fontSize: 24,
- fontWeight: '600',
+ center: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
},
- sectionDescription: {
- marginTop: 8,
- fontSize: 18,
- fontWeight: '400',
+ loadingText: {
+ marginTop: 12,
+ color: '#666',
+ fontSize: 14,
},
- highlight: {
- fontWeight: '700',
+ errorText: {
+ color: '#c33',
+ fontSize: 14,
+ textAlign: 'center',
+ },
+ watchText: {
+ paddingHorizontal: 12,
+ paddingTop: 8,
+ color: '#555',
+ fontSize: 12,
+ },
+ canvasHost: {
+ flex: 1,
+ position: 'relative',
+ },
+ initOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(255, 255, 255, 0.72)',
+ gap: 8,
+ },
+ initOverlayText: {
+ color: '#666',
+ fontSize: 13,
+ },
+ sessionText: {
+ paddingHorizontal: 12,
+ paddingBottom: 8,
+ color: '#2a7',
+ fontSize: 12,
},
});
diff --git a/README.md b/README.md
index 3e2c3f8..072d454 100644
--- a/README.md
+++ b/README.md
@@ -1,97 +1,904 @@
-This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
+# react-native-mask-segment-canvas
-# Getting Started
+基于 React Native **0.79** 的掩码分区交互库,核心导出 `MaskSegmentCanvas` 组件,可通过 **npm 包** 或 **npm link** 接入其它 RN 工程。
-> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
+- **OpenCV**(`react-native-fast-opencv`):掩码语义布局、踢脚线修补、分区提取
+- **Skia RuntimeEffect(SkSL)**:原图 + LAB 高低频纹理叠色(单次全屏 Shader)
+- **Skia Path**:分区虚线轮廓高亮
+- **交互**:底部笔刷选色(可选初始化)→ 点击分区上色;未选笔刷时点击分区会通过 `onPaintCallback` 提示;未选笔刷时长按预览分区虚线轮廓
-## Step 1: Start Metro
+本仓库同时作为 **库源码**(`src/index.ts`)与 **自测 Demo**(根目录 `App.tsx`)维护。
-First, you will need to run **Metro**, the JavaScript build tool for React Native.
+**推荐的集成演示请查看 `example/` 目录**:它只使用公开 API,完整模拟业务项目接入方式(含 `package.json`、Metro 配置、完整可参考的 `App.tsx`)。
-To start the Metro dev server, run the following command from the root of your React Native project:
+---
+
+## 作为 npm 包接入其它工程
+
+### 安装依赖(宿主工程)
+
+```bash
+npm install react-native-mask-segment-canvas
+# 或本地联调
+npm link ../MaskSegmentApp # 在库目录先执行 npm link
+npm link react-native-mask-segment-canvas
+```
+
+宿主工程还需安装 **peerDependencies**(版本需与宿主 RN 对齐):
+
+```bash
+npm install @shopify/react-native-skia react-native-reanimated react-native-fast-opencv react-native-fs buffer upng-js
+# 若使用 showDebugPickers 相册选图
+npm install react-native-image-picker
+```
+
+### 宿主工程 postinstall(必需)
+
+本库依赖 `patch-package` 修补 `react-native-fast-opencv`,宿主 `package.json` 需配置:
+
+```json
+{
+ "scripts": {
+ "postinstall": "patch-package"
+ },
+ "devDependencies": {
+ "patch-package": "^8.0.1"
+ }
+}
+```
+
+安装本库后,`node_modules/react-native-mask-segment-canvas/patches/` 中的补丁会在宿主 `postinstall` 时自动应用。
+
+### iOS / Android 原生依赖
+
+```bash
+cd ios && pod install && cd ..
+```
+
+确保宿主已按各原生库文档完成 Skia、Reanimated、OpenCV 等配置。
+
+### Metro 配置(npm link 时)
+
+联调时若出现模块解析问题,在宿主 `metro.config.js` 中把本库加入 `watchFolders` / `extraNodeModules`(按宿主 Metro 版本调整):
+
+```js
+const path = require('path');
+
+module.exports = {
+ watchFolders: [path.resolve(__dirname, '../MaskSegmentApp')],
+ resolver: {
+ nodeModulesPaths: [path.resolve(__dirname, 'node_modules')],
+ },
+};
+```
+
+### 业务侧引入
+
+```tsx
+import MaskSegmentCanvas, {
+ type MaskSegmentCanvasRef,
+ 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';
+```
+
+主要导出一览:
+
+| 类别 | 名称 |
+| ---- | ---- |
+| 组件 | `MaskSegmentCanvas`(default) |
+| Ref / Props 类型 | `MaskSegmentCanvasRef`、`MaskSegmentCanvasProps` |
+| 会话 / 回调类型 | `MaskSegmentSession`、`PaintCallbackPayload`、`PaintedRegionRecord`、`SavePaintResult` |
+| Watch 类型 | `MaskSegmentWatchState`、`MaskSegmentWatchDetail` |
+| 配置类型 | `PipelineConfig`、`MaskSegmentConfig`、`PaintConfig`、`InteractionConfig` |
+| 语义色 | `MASK_SEMANTIC_COLORS`、`BASEBOARD_SEMANTIC_NAME` |
+| 工具 | `prewarmPngBgrCacheAsync`、`prewarmPngBgrCache` |
+| 运行时 | `DEFAULT_*_CONFIG`、`getMaskSegmentRuntimeConfig`、`setMaskSegmentRuntimeConfig` |
+
+---
+
+## 推荐:通过 example/ 目录学习集成
+
+`example/` 是**专门为业务侧集成准备的演示文件夹**,它:
+
+- 只通过 `import ... from 'react-native-mask-segment-canvas'` 使用公开 API(不碰内部 src)
+- 提供了独立的 `package.json`(含 peer deps + 本地 file 依赖)
+- 包含针对本地联调的 `metro.config.js`
+- `App.tsx` 是一个可直接参考的完整页面,涵盖预热、状态管理、ref 操作、回调处理等
+
+建议:
+
+1. 直接阅读 `example/App.tsx` 获取最新可运行的集成写法。
+2. 按 `example/README.md` 的步骤在本机跑起来,验证安装、patch、Metro 配置是否正确。
+3. 把 `example/App.tsx` 中的核心逻辑复制到你自己的页面/组件中即可。
+
+这样可以确保你接入的是「库的公开契约」,而不是内部实现细节。
+
+---
+
+## 环境要求
+
+- Node.js >= 18(推荐 20+)
+- Xcode 15+(iOS)
+- Android Studio + JDK 17(Android)
+- CocoaPods(iOS)
+
+## 快速开始(本仓库 Demo)
+
+根目录 `App.tsx` 是库作者自测用的完整 Demo,内部直接 import `./src`。
+
+```bash
+cd MaskSegmentApp
+
+npm install
+
+cd ios && bundle exec pod install && cd ..
-```sh
-# Using npm
npm start
-# OR using Yarn
-yarn start
-```
-
-## Step 2: Build and run your app
-
-With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
-
-### Android
-
-```sh
-# Using npm
-npm run android
-
-# OR using Yarn
-yarn android
-```
-
-### iOS
-
-For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
-
-The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
-
-```sh
-bundle install
-```
-
-Then, and every time you update your native dependencies, run:
-
-```sh
-bundle exec pod install
-```
-
-For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
-
-```sh
-# Using npm
+# 另开终端
npm run ios
-
-# OR using Yarn
-yarn ios
+# 或
+npm run android
```
-If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
+**想看「纯业务项目如何集成」**:请进入 `example/` 目录,按其 `README.md` 操作。它使用 `import from 'react-native-mask-segment-canvas'` + 标准的 `package.json` + Metro 配置,完全模拟消费者环境。
-This is one way to run your app — you can also build it directly from Android Studio or Xcode.
+Demo 入口 `App.tsx` 通过 `./src`(即包入口 `src/index.ts`)引用组件,与业务侧 `import from 'react-native-mask-segment-canvas'` 等价。
-## Step 3: Modify your app
+---
-Now that you have successfully run the app, let's make changes!
+## MaskSegmentCanvas 组件
-Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
+### 引入
-When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
+```tsx
+import React, { useRef } from 'react';
+import MaskSegmentCanvas, {
+ type MaskSegmentCanvasRef,
+ type MaskSegmentSession,
+ type MaskSegmentWatchState,
+ type MaskSegmentWatchDetail,
+ type BgrColor,
+ type MaskSemanticColor,
+ type PaintCallbackPayload,
+ MASK_SEMANTIC_COLORS,
+ prewarmPngBgrCacheAsync,
+} from 'react-native-mask-segment-canvas';
+```
-- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS).
-- **iOS**: Press R in iOS Simulator.
+也可按需导入运行时默认值(与组件 Props 合并使用):
-## Congratulations! :tada:
+```tsx
+import {
+ DEFAULT_PIPELINE_CONFIG,
+ DEFAULT_MASK_CONFIG,
+ DEFAULT_PAINT_CONFIG,
+ DEFAULT_INTERACTION_CONFIG,
+} from 'react-native-mask-segment-canvas';
+```
-You've successfully run and modified your React Native App. :partying_face:
+### 最小示例
-### Now what?
+下面是一个可直接放进业务页面的完整示例,涵盖 **PNG 预热**、**state**、**配置**、**加载态**、**onWatch** 与 **ref** 常用操作。
-- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
-- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
+```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';
-# Troubleshooting
+/** 业务侧准备好的图片地址(本地 file:// 或 http(s)://) */
+type ImagePaths = {
+ origin: string;
+ mask: string;
+};
-If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
+const INTERACTIVE_STATES: MaskSegmentWatchState[] = [
+ 'interactive',
+ 'mask_paths_ready',
+];
-# Learn More
+export function PaintScreen() {
+ const canvasRef = useRef(null);
-To learn more about React Native, take a look at the following resources:
+ 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';
+
+ // 示例:接口下载完成后写入路径,并预热 PNG 解码缓存
+ 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 (
+
+
+ 等待原图与掩码…
+
+ );
+ }
+
+ return (
+
+ {isCanvasLoading ? 加载中:{watchState} : null}
+ {watchState === 'interactive' ? (
+ 可上色(轮廓加载中…)
+ ) : null}
+ {isOutlineReady ? 就绪 : null}
+ {errorMessage ? {errorMessage} : null}
+
+ {
+ setWatchState(state);
+ // detail: regionCount, maskPathsReady, freqLayersReady, errorMessage
+ 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');
+ }}
+ />
+
+ {/* ref 示例:disabled={!isInteractive} 时可按需绑定 */}
+ {/* */}
+ {/*
+ );
+}
+```
+
+#### 示例里涉及的 state 说明
+
+
+| state | 类型 | 用途 |
+| ------------------ | -------------------------- | ---- |
+| `imagePaths` | `{ origin, mask } \| null` | 业务侧解析后的本地/远程图片路径 |
+| `pathsError` | `string` | 路径解析或 PNG 预热失败文案 |
+| `watchState` | `MaskSegmentWatchState \| ''` | `onWatch` 上报的初始化阶段 |
+| `isInteractive` | 派生 | `interactive` 或 `mask_paths_ready` 时为 true,可开放操作 |
+| `isOutlineReady` | 派生 | `mask_paths_ready` 时为 true,轮播虚线已就绪 |
+| `isCanvasLoading` | 派生 | 画布初始化阻塞 Loading(不含 PNG 路径等待) |
+| `errorMessage` | `string` | 由 `onError` 写入的分割/加载失败文案 |
+| `sessionDraft` | `MaskSegmentSession \| null` | MMKV 等恢复的草稿 |
+
+
+#### 配置项怎么选
+
+
+| 配置 | 何时用顶层属性 | 何时用嵌套 Config |
+| ---------- | -------------------------------- | ----------------------------------------- |
+| 语义识别色 | `semanticColors={...}` 大多数场景 | `maskConfig.semanticColors` 需与更多掩码参数一起传时 |
+| 虚线颜色 | `regionOutlineColor="..."` 大多数场景 | `paintConfig.regionOverlayFill` 需同时改笔刷盘等时 |
+| 黑色阈值、最大分区数 | — | `maskConfig` |
+| 图片处理尺寸 | — | `pipelineConfig` |
+| 轮播间隔、点击容差 | — | `interactionConfig` |
+
+
+顶层属性与嵌套 Config **可同时传**,顶层 `semanticColors` / `regionOutlineColor` 优先级更高。
+
+#### watchState 与 UI 建议
+
+```ts
+// 阻塞式 Loading(分区 + 上色图层就绪前)
+const isLoading = ![
+ 'interactive',
+ 'mask_paths_ready',
+ 'error',
+ '',
+].includes(watchState);
+
+// 允许点击选区、选色、上色(不必等轮廓路径)
+const canOperate =
+ watchState === 'interactive' || watchState === 'mask_paths_ready';
+
+// 初始化轮播虚线已全部就绪(可选,用于收起「轮廓准备中」提示)
+const isOutlineReady = watchState === 'mask_paths_ready';
+
+// 显示错误页
+const hasError = watchState === 'error';
+```
+
+`interactive` 时 `detail.maskPathsReady` 一般为 `false`;`mask_paths_ready` 时为 `true`。两者通常相差约 100ms(异步构建 Skia 轮廓路径),不影响点击上色。
+
+`originUrl` / `maskUrl` 支持:
+
+- 本地路径:`file:///...` 或绝对路径
+- 远程地址:`http(s)://...`(组件内部会下载/解析)
+
+> 兼容旧属性 `originImgPath` / `maskImgPath`(已标记 deprecated,请改用 `originUrl` / `maskUrl`)。
+
+---
+
+## Props
+
+### 图片与初始化
+
+| 属性 | 类型 | 必填 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- | ---- |
+| `originUrl` | `string` | 是* | — | 原图地址(`file://`、绝对路径或 `http(s)://`) |
+| `maskUrl` | `string` | 是* | — | 掩码图地址(语义色块图,建议与原图同尺寸) |
+| `originImgPath` | `string` | — | — | **deprecated**,请用 `originUrl` |
+| `maskImgPath` | `string` | — | — | **deprecated**,请用 `maskUrl` |
+| `initialSession` | `MaskSegmentSession` | 否 | — | 从 MMKV 等恢复的草稿;分区就绪后自动 `loadSession` |
+| `initialPaintColor` | `BgrColor` | 否 | — | **可选**。初始自定义笔刷色 `{ b, g, r }`;不传则默认无笔刷,需用户选色或 `ref.setPaintColor` |
+| `initialPaintConfigJson` | `Record` | 否 | — | **可选**。与 `initialPaintColor` 配套的笔刷配置,上色成功时随 `onPaintCallback` 回传 |
+
+### 识别色与虚线(顶层便捷配置)
+
+| 属性 | 类型 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- |
+| `semanticColors` | `MaskSemanticColor[]` | `MASK_SEMANTIC_COLORS` | 掩码语义识别色,等同 `maskConfig.semanticColors` |
+| `regionOutlineColor` | `string` | `rgba(20, 120, 235, 0.58)` | 分区虚线高亮色,等同 `paintConfig.regionOverlayFill` |
+
+顶层属性优先级高于嵌套 `maskConfig` / `paintConfig`。
+
+`MaskSemanticColor` 结构:
+
+```ts
+{
+ name: string; // 语义名,如 wall / ceiling / baseboard
+ hex: string; // 展示用十六进制色
+ bgr: { b: number; g: number; r: number }; // 与掩码像素 BGR 通道一致
+}
+```
+
+内置色表:`MASK_SEMANTIC_COLORS`(`src/utils/maskSemanticPalette.ts`)。
+
+### maskConfig
+
+| 字段 | 类型 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- |
+| `semanticColors` | `MaskSemanticColor[]` | 内置色表 | 掩码语义色(可被顶层 `semanticColors` 覆盖) |
+| `blackThreshold` | `number` | `30` | BGR 最大值低于此值的像素视为黑色背景 |
+| `maxRegionColors` | `number` | `6` | 最终保留的最大语义分区数 |
+| `quantStep` | `number` | `64` | 踢脚线量化步长 |
+| `baseboardMaxColorDist` | `number` | `42` | 踢脚线色距阈值 |
+| `baseboardStripQuantKeys` | `string[]` | 内置键集 | 踢脚线条带量化键,格式 `"b,g,r"` |
+| `wallQuantKeys` | `string[]` | 内置键集 | 墙面量化键 |
+| `cabinetQuantKeys` | `string[]` | 内置键集 | 柜体量化键 |
+| `secondarySemanticNames` | `string[]` | `garageDoor, roof, eave` | 次要语义名 |
+| `secondaryMinPixelRatio` | `number` | `0.002` | 次要语义最小像素占比 |
+| `junctionHRadiusPx` | `number` | `24` | 踢脚线交界水平半径 |
+| `junctionVRadiusPx` | `number` | `2` | 踢脚线交界垂直半径 |
+| `kickBridgeHalfWPx` | `number` | `6` | 踢脚线横向补缝半宽 |
+| `baseboardJunctionRowMarginPx` | `number` | `1` | 踢脚线交界行边距 |
+| `baseboardJunctionVReachPx` | `number` | `2` | 踢脚线交界纵向延伸 |
+| `baseboardMinRunPx` | `number` | `2` | 蒙版条带最小 run 长度 |
+
+### pipelineConfig
+
+| 字段 | 类型 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- |
+| `maxImageLongSide` | `number` | `720` | 分割 / pickMap / 工作区缩放最长边 |
+| `paintFreqMaxLongSide` | `number` | `480` | OpenCV LAB 高低频最长边 |
+| `originPreviewMaxLongSide` | `number` | `360` | 预览最长边(主路径走工作区分辨率) |
+| `maskPathMaxLongSide` | `number` | `480` | 虚线轮廓降采样最长边 |
+| `minContourArea` | `number` | `100` | 最小轮廓面积(随缩放同比缩放) |
+| `contourApproxEpsilon` | `number` | `0.003` | 轮廓多边形逼近系数 |
+| `maxRegions` | `number` | `500` | 分割阶段最大区域数上限 |
+
+### paintConfig
+
+| 字段 | 类型 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- |
+| `palette` | `BgrColor[]` | 6 色内置盘 | 底部笔刷色条 |
+| `colorBaseOpacity` | `number` | `0.88` | 底色不透明度 |
+| `lLightOpacity` | `number` | `0.50` | L 通道叠加强度 |
+| `textureOpacity` | `number` | `0.85` | 高频纹理叠加强度(纹理保留更强) |
+| `lLowBlurKernel` | `number` | `7` | 低频高斯核(奇数) |
+| `lLowContrast` | `number` | `1.15` | 低频对比度 |
+| `lLowBrightness` | `number` | `0.9` | 低频亮度 |
+| `lHighGain` | `number` | `1.22` | 高频增益 |
+| `maskFeatherColor` | `number` | `1.6` | 上色边缘羽化(颜色)——软边 alpha 半径,像素 |
+| `maskFeatherTexture` | `number` | `0.9` | 上色边缘羽化(纹理)——预留/辅助 |
+| `regionOverlayFill` | `string` | `rgba(20,120,235,0.58)` | 虚线/高亮填充色 |
+| `regionOutlineStrokeWidth` | `number` | `4` | 虚线描边宽度 |
+
+### interactionConfig
+
+| 字段 | 类型 | 默认 | 说明 |
+| ---- | ---- | ---- | ---- |
+| `pickMapSearchRadiusPx` | `number` | `14` | 点击 pickMap 搜索半径(像素) |
+| `kickMaskPickRadiusPx` | `number` | `36` | 踢脚线掩码拾取半径 |
+| `thinStripPadding` | `number` | `0.008` | 细条带(踢脚线)点击扩展比例 |
+| `regionPadding` | `number` | `0.003` | 普通分区点击扩展比例 |
+| `initRegionFlashMs` | `number` | `1000` | 初始化轮播每条虚线停留毫秒 |
+| `enableInitRegionFlash` | `boolean` | `true` | 是否启用初始化轮播 |
+
+> 完整默认值常量:`DEFAULT_MASK_CONFIG`、`DEFAULT_PIPELINE_CONFIG`、`DEFAULT_PAINT_CONFIG`、`DEFAULT_INTERACTION_CONFIG`(自包入口导出)。
+
+### UI 开关与样式
+
+
+| 属性 | 类型 | 默认 | 说明 |
+| ------------------------------------------------ | ---------------------- | ------- | ---------------------- |
+| `showToolbar` | `boolean` | `true` | 顶部「清空缓存重新分区」工具栏 |
+| `showColorBar` | `boolean` | `true` | 底部笔刷色条 |
+| `showStatusRow` | `boolean` | `true` | 分割/加载状态文案 |
+| `showOverlayButtons` | `boolean` | `true` | 左下撤销、右下对比原图按钮 |
+| `showDebugPickers` | `boolean` | `true` | 相册选图调试入口(生产建议 `false`) |
+| `disabled` | `boolean` | `false` | 禁用上色交互 |
+| `style` | `ViewStyle` | — | 外层容器样式 |
+| `canvasStyle` | `ViewStyle` | — | 画布区域样式 |
+| `undoButtonStyle` / `compareButtonStyle` | `ViewStyle` | — | 浮层按钮样式 |
+| `undoButtonTextStyle` / `compareButtonTextStyle` | `TextStyle` | — | 浮层按钮文字样式 |
+| `undoButtonText` | `string` | `撤销` | 撤销按钮文案 |
+| `compareButtonText` | `string` | `对比原图` | 进入对比模式文案 |
+| `compareExitButtonText` | `string` | `退出对比` | 退出对比模式文案 |
+| `renderUndoButton` | `(props) => ReactNode` | — | 自定义撤销按钮 |
+| `renderCompareButton` | `(props) => ReactNode` | — | 自定义对比按钮 |
+
+
+### 回调
+
+| 属性 | 签名 | 说明 |
+| ---- | ---- | ---- |
+| `onWatch` | `(state, durationMs, detail?) => void` | 初始化阶段回调;`durationMs` 自本次 `init` 起算 |
+| `onPaintCallback` | `(payload: PaintCallbackPayload) => void` | 上色成功或未选笔刷时点击分区 |
+| `onError` | `(message, error?) => void` | 分割或加载失败 |
+
+`PaintCallbackPayload`(判别联合,`payload.kind` 区分):
+
+```ts
+// 上色成功
+{
+ kind: 'painted';
+ regionId: number;
+ regionName: string;
+ color: BgrColor;
+ configJson?: Record; // setPaintColor / initialPaintConfigJson 传入
+}
+
+// 未选笔刷时点击有效分区(不会上色)
+{
+ kind: 'brush_required';
+ hint: string; // 如「请先选择笔刷颜色(底部色条或 ref.setPaintColor)」
+ regionId: number;
+ regionName: string;
+}
+```
+
+示例:
+
+```tsx
+onPaintCallback={payload => {
+ if (payload.kind === 'brush_required') {
+ showToast(payload.hint);
+ return;
+ }
+ savePaintRecord(payload.regionId, payload.color, payload.configJson);
+}}
+```
+
+`onWatch` 的 `detail`(`MaskSegmentWatchDetail`):
+
+| 字段 | 类型 | 说明 |
+| ---- | ---- | ---- |
+| `regionCount` | `number` | 当前有效分区数 |
+| `maskPathsReady` | `boolean` | 轮廓 Skia 路径是否就绪 |
+| `freqLayersReady` | `boolean` | 高低频 Shader 纹理是否就绪 |
+| `errorMessage` | `string` | `error` 状态下的失败说明 |
+
+#### onWatch 状态流转
+
+```
+init
+ → images_loaded 原图 + 掩码读取完成
+ → mask_aligned 掩码尺寸对齐完成
+ → mask_sampled 掩码像素采样完成
+ → regions_ready 分区提取成功
+ → layers_ready 上色纹理图层就绪(detail.maskPathsReady 可能仍为 false)
+ → interactive 可交互(可点击选区、选色、上色)
+ → mask_paths_ready 轮廓路径就绪(初始化轮播虚线可显示,detail.maskPathsReady 为 true)
+ → error 失败(detail.errorMessage 有说明)
+```
+
+`layers_ready` / `interactive` 可能在轮廓路径算完之前触发;业务侧若以 `interactive` 关闭 Loading,用户即可操作,轮播虚线会在 `mask_paths_ready` 后自动出现。
+
+---
+
+## Ref 方法
+
+通过 `ref` 调用(类型 `MaskSegmentCanvasRef`):
+
+| 方法 | 签名 | 说明 |
+| ---- | ---- | ---- |
+| `reset` | `() => void` | 撤销上一步上色(按 `paintHistory`) |
+| `swap` | `(showOrigin?: boolean) => void` | 对比原图;不传参 toggle,传 `true`/`false` 显式开关 |
+| `save` | `(options?) => Promise` | 合成并保存 PNG;`options.destDir` 可选输出目录 |
+| `session` | `() => MaskSegmentSession` | 导出可 JSON 序列化会话(存 MMKV) |
+| `loadSession` | `(session) => void` | 恢复上色状态(也可通过 `initialSession`) |
+| `setPaintColor` | `(color, configJson?) => void` | 设置当前笔刷色,清空底部色条选中 |
+| `setMaskConfig` | `(config) => void` | 运行时更新掩码配置并**重新分割** |
+| `clearAllPaint` | `() => void` | 清空全部上色记录 |
+| `resegment` | `() => Promise` | 清空 PNG 缓存并重新分割 |
+| `getRegions` | `() => SegmentRegion[]` | 当前分区列表快照 |
+| `getPaintedRegions` | `() => PaintedRegionRecord[]` | 当前上色记录快照 |
+
+`SavePaintResult`:`{ filePath, width, height, paintedCount, previewPath? }`
+
+代码示例:
+
+```tsx
+const ref = useRef(null);
+
+ref.current?.reset();
+ref.current?.swap(); // toggle
+ref.current?.swap(true); // 强制显示原图
+
+const result = await ref.current?.save({ destDir: '/path/to/dir' });
+
+const session = ref.current?.session();
+ref.current?.loadSession(session);
+
+ref.current?.setPaintColor({ b: 100, g: 120, r: 140 }, { sku: 'paint-001' });
+ref.current?.setMaskConfig({ semanticColors: customColors });
+
+ref.current?.clearAllPaint();
+await ref.current?.resegment();
+
+const regions = ref.current?.getRegions();
+const painted = ref.current?.getPaintedRegions();
+```
+
+> `save` 依赖工作区 buffer 与 pickMap 就绪(通常 `interactive` 之后);未就绪时 throw `'图像尚未就绪,无法保存'`。
+
+### 存储约定
+
+
+| 能力 | 建议存储 | 内容 |
+| --------------- | ------------------- | ----------------------- |
+| `ref.save()` | 文件系统 | 大图 PNG 路径 |
+| `ref.session()` | MMKV / AsyncStorage | JSON 元数据(URL、上色记录、笔刷色等) |
+
+
+`MaskSegmentSession` 结构:
+
+```ts
+{
+ version: 1;
+ originUrl: string;
+ maskUrl: string;
+ painted: PaintedRegionRecord[]; // { regionId, regionName, color, configJson? }
+ paintHistory: number[];
+ currentColor?: BgrColor;
+ currentColorConfigJson?: Record;
+ savedAt: number;
+}
+```
+
+---
+
+## 交互说明
+
+1. **初始化轮播**:分区就绪后,按 `initRegionFlashMs`(默认 1s)轮播各分区虚线轮廓;用户触摸画布后停止。
+2. **预览(未选笔刷)**:长按分区显示当前触摸点所在连通块的虚线轮廓;点击黑色区域不显示虚线。
+3. **上色(已选笔刷)**:先点底部色条或 `ref.setPaintColor`(也可通过 `initialPaintColor` 预设),再点击分区上色;同一分区重复点击会覆盖颜色。
+4. **未选笔刷点击分区**:不会上色;`onPaintCallback` 以 `kind: 'brush_required'` 回调,携带 `hint` 与目标分区信息,由业务侧 Toast / 弹窗提示用户选色。
+5. **撤销**:左下角按钮或 `ref.reset()`,按上色历史逐步撤回。
+6. **对比原图**:右下角按钮或 `ref.swap()`,隐藏上色层查看原图。
+
+---
+
+## 接入业务示例
+
+### 下载后预热再挂载(推荐)
+
+```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 });
+}
+```
+
+### 接口下载后传入本地路径
+
+```tsx
+ {
+ if (state === 'interactive') hideBlockingLoader();
+ if (state === 'mask_paths_ready') hideOutlineHint();
+ }}
+/>
+```
+
+### 草稿恢复
+
+```tsx
+const draft = JSON.parse(mmkv.getString('paint_draft'));
+
+
+```
+
+### 自定义语义色表
+
+```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 } },
+ // ...
+];
+
+
+```
+
+---
+
+## 项目结构
+
+```
+MaskSegmentApp/ # 仓库根目录(npm 包 react-native-mask-segment-canvas)
+├── App.tsx # 库自测 Demo(直接引用 ./src)
+├── src/
+│ ├── index.ts # npm 包入口(业务 import 'react-native-mask-segment-canvas')
+│ ├── components/
+│ │ ├── MaskSegmentCanvas.tsx
+│ │ └── MaskSegmentCanvas.types.ts
+│ └── utils/
+│ ├── maskSegmentation.ts
+│ ├── maskSegmentRuntime.ts
+│ ├── maskSemanticPalette.ts
+│ └── ...
+├── example/ # ★ 推荐:业务集成演示(消费者视角)
+│ ├── App.tsx # 只使用公开 API 的完整示例页面
+│ ├── index.js / app.json
+│ ├── package.json # 展示所需依赖 + "react-native-mask-segment-canvas": "file:.."
+│ ├── metro.config.js / babel.config.js / tsconfig.json
+│ └── README.md # 如何在真实项目中接入的说明
+├── patches/ # 随包发布,宿主 postinstall 应用
+├── ios/ # 根 Demo 原生工程(不发布到 npm)
+└── android/
+```
+
+---
+
+## 依赖说明
+
+
+| 包 | 用途 |
+| -------------------------------- | ---------------------------- |
+| `@shopify/react-native-skia` | Canvas 渲染、Path、虚线描边、Blend 混合 |
+| `react-native-fast-opencv` | 掩码形态学、轮廓处理 |
+| `react-native-fs` | 图层缓存、保存 PNG |
+| `react-native-image-picker` | Demo 相册选图 |
+| `react-native-reanimated` | Skia 动画依赖 |
+| `react-native-safe-area-context` | 安全区适配 |
+
+
+---
+
+## 性能评测
+
+以下数据基于 Demo 测试图(`assets/test/origin.png` **1080×1920**、6 个语义分区)、**默认 `pipelineConfig`**,以及 `onWatch` 的 `durationMs`(从 `init` 起算)。为**经验区间**,非严格基准测试;真机因 CPU、存储、RN 版本会有波动。
+
+### 实测参考(开发环境 + PNG 预热)
+
+Demo 在挂载画布前调用 `prewarmPngBgrCacheAsync([origin, mask])`,PNG 解码命中内存缓存。典型日志:
+
+| 阶段 | watchState | 约耗时 | 说明 |
+| ---- | ---------- | ------ | ---- |
+| 掩码对齐 | `mask_aligned` | ~160ms | 掩码缩放到分割工作分辨率 |
+| 分区完成 | `regions_ready` / `mask_sampled` | ~320ms | 布局扫描 + 踢脚线 + pickMap |
+| **可交互** | **`interactive`** | **~320–450ms** | 可点击选区、选色、Shader 上色 |
+| 轮廓就绪 | `mask_paths_ready` | ~430–550ms | 比 `interactive` 晚 **~100ms**,轮播虚线可显示 |
+
+`interactive` **不等待**轮廓路径;`mask_paths_ready` 仅影响初始化轮播虚线与可选 UI 提示。
+
+同图各子步骤(`__DEV__` 日志,默认 pipeline)量级:
+
+| 子步骤 | 约耗时 | 工作分辨率 |
+| ------ | ------ | ---------- |
+| OpenCV LAB 高低频 | ~10–40ms | 270×480 |
+| 高低频 Skia 纹理 | ~20–30ms | 同上 |
+| 布局扫描 + 踢脚线 + 点击查表 | ~90–120ms | 405×720(1080p 缩至 longSide 720) |
+| 全量轮廓路径(异步,不阻塞交互) | ~80–150ms | 270×480 |
+
+### 分辨率与 `pipelineConfig` 的关系
+
+计算密集型步骤被 **最长边上限** 截断,**不随 4K/8K 原图线性放大**;**PNG 全图解码**仍随像素量线性增长。
+
+| 步骤 | 配置项 | 1080×1920 实际处理尺寸 | 随原图像素增长 |
+| ---- | ------ | ---------------------- | -------------- |
+| PNG 解码 | — | 1080×1920 × 2 张 | **是** |
+| 掩码分割 / pickMap | `maxImageLongSide: 720` | ~405×720 | **否**(长边 >720 时固定) |
+| Shader 高低频 | `paintFreqMaxLongSide: 480` | ~270×480 | **否** |
+| 工作区 Skia 原图 | 同 `maxImageLongSide` | ~405×720 | **否** |
+| 虚线轮廓 | `maskPathMaxLongSide: 480` | ~270×480 | **否**(不阻塞 `interactive`) |
+
+### `interactive` 预估(默认 pipeline)
+
+| 原图规格 | 相对 1080p 像素 | 有 PNG 预热 | 冷启动(无预热) |
+| -------- | --------------- | ----------- | ---------------- |
+| 1080×1920 | 1× | **320–450ms** | **450–700ms** |
+| 1440×2560 (2K) | ~1.8× | **400–550ms** | **600–900ms** |
+| 3840×2160 (4K) | ~4× | **500–750ms** | **800–1200ms** |
+| 7680×4320 (8K) | ~16× | **0.8–1.5s** | **1.5–3s+** |
+
+> **300ms 内可交互**:在 1080p + 预热 + 默认 pipeline + 中高端机上**接近但偏乐观**;不宜作为全机型 SLA。
+
+### 机型档位(1080p,默认 pipeline)
+
+相对上述开发环境 ~320ms 的量级:
+
+| 档位 | 相对倍数 | 有预热 `interactive` | 冷启动 |
+| ---- | -------- | -------------------- | ------ |
+| 旗舰 iOS / 新旗舰 Android | 0.8–1.2× | 300–450ms | 500–800ms |
+| 中端 Android | 1.5–2.5× | 500–800ms | 700ms–1.2s |
+| 低端 Android(4GB、老 U) | 2.5–4× | 800ms–1.3s | 1–2s+ |
+
+Android 额外开销主要来自:JS ↔ OpenCV bridge、内存带宽/GC、Skia 纹理上传。
+
+### 提高 `maxImageLongSide` 的影响
+
+若将 `pipelineConfig.maxImageLongSide` 设为 **1280**(高于默认 720),分割工作区约 **720×1280**,像素约为 720 档的 **3×**:
+
+| 场景 | 默认 720 | 改为 1280 |
+| ---- | -------- | --------- |
+| 1080p `interactive`(中端机) | ~320–800ms | **500ms–1s+** |
+| 分割 / pickMap 耗时 | ~90–120ms | ~250–350ms |
+
+更高精度换更长初始化;若目标仍是 **<500ms 可交互**,建议维持默认 **720**,必要时降至 **640**。
+
+### 优化建议
+
+1. **PNG 预热(推荐)**:下载或解压完成后、进入画面前调用 `prewarmPngBgrCacheAsync`,通常可省 **100–250ms**(低端机收益最大)。
+
+```tsx
+import { prewarmPngBgrCacheAsync } from 'react-native-mask-segment-canvas';
+
+await prewarmPngBgrCacheAsync([originPath, maskPath]);
+// 再挂载 MaskSegmentCanvas
+```
+
+2. **Loading 时机**:阻塞式 Loading 在 `interactive` 关闭;「轮廓准备中」可选监听 `mask_paths_ready`。
+3. **大图 / 低端机**:保持默认 `maxImageLongSide: 720`;可再将 `paintFreqMaxLongSide` 降至 **360**。
+4. **4K 素材**:业务侧先下采样再传入,或接受 **0.8–1.5s** 量级的 `interactive`(预热后)。
+5. **观测**:开发环境关注 Metro 中 `[MaskSegment]`、`[⏱ ...]` 与 `onWatch` 的 `durationMs`。
+
+---
+
+## 注意事项
+
+- 掩码图应为与原图同尺寸的语义色块图(黑底 + 纯色分区);黑色区域(`blackThreshold` 默认 30 以下)不参与分区。
+- OpenCV 分割在 JS 线程执行,超大图可能卡顿;可通过 `pipelineConfig.maxImageLongSide` 限制处理尺寸。
+- iOS 相册选图需相册权限(仅 `showDebugPickers` 开启时用到)。
+- `semanticColors` 须与后端/标注掩码的语义色保持一致,否则识别会偏移。
+
+---
+
+## 故障排查
+
+**iOS pod install 失败**
+
+```bash
+cd ios
+bundle install
+bundle exec pod install --repo-update
+```
+
+**Android 编译报错**
+
+```bash
+cd android && ./gradlew clean && cd ..
+```
+
+**分割失败 / 分区为空**
+
+- 确认 `originUrl` / `maskUrl` 可访问
+- 确认掩码语义色与 `semanticColors` 配置一致
+- 查看 Metro 日志中的 `[MaskSegment]` / `[⏱ ...]` 输出
+
+**虚线不贴边 / 出现多余轮廓**
+
+- 虚线基于掩码像素外轮廓生成,长按仅显示触摸点所在连通块
+- 初始化轮播仅显示该语义分区最大连通块
-- [React Native Website](https://reactnative.dev) - learn more about React Native.
-- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
-- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
-- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
-- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.
diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx
index e532f70..76aff1d 100644
--- a/__tests__/App.test.tsx
+++ b/__tests__/App.test.tsx
@@ -2,12 +2,80 @@
* @format
*/
-import React from 'react';
-import ReactTestRenderer from 'react-test-renderer';
-import App from '../App';
+import type { MaskSegmentSession } from '../src/components/MaskSegmentCanvas.types';
+import { createRuntimeConfig } from '../src/utils/maskSegmentRuntime';
-test('renders correctly', async () => {
- await ReactTestRenderer.act(() => {
- ReactTestRenderer.create();
+jest.mock('react-native', () => ({
+ View: 'View',
+ Text: 'Text',
+ StyleSheet: { create: (s: object) => s, hairlineWidth: 1 },
+ ActivityIndicator: 'ActivityIndicator',
+ StatusBar: 'StatusIndicator',
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ SafeAreaProvider: ({ children }: { children: unknown }) => children,
+ SafeAreaView: ({ children }: { children: unknown }) => children,
+}));
+
+jest.mock('../src/components/MaskSegmentCanvas', () => {
+ const React = require('react');
+ const { forwardRef } = React;
+ return {
+ __esModule: true,
+ default: forwardRef(() => null),
+ };
+});
+
+jest.mock('../src/utils/pngImage', () => ({
+ prewarmPngBgrCacheAsync: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('../src/utils/resolveAssetPath', () => ({
+ resolveAssetPath: jest.fn((_asset: number, name: string) =>
+ Promise.resolve(`file:///mock/${name}`),
+ ),
+}));
+
+test('session round-trip shape', () => {
+ const session: MaskSegmentSession = {
+ version: 1,
+ originUrl: 'file:///mock/origin.png',
+ maskUrl: 'file:///mock/mask.png',
+ painted: [
+ {
+ regionId: 0,
+ regionName: 'wall',
+ color: { b: 100, g: 120, r: 140 },
+ configJson: { sku: 'paint-001' },
+ },
+ ],
+ paintHistory: [0],
+ currentColor: { b: 100, g: 120, r: 140 },
+ currentColorConfigJson: { sku: 'paint-001' },
+ savedAt: Date.now(),
+ };
+
+ const restored = JSON.parse(JSON.stringify(session)) as MaskSegmentSession;
+ expect(restored.version).toBe(1);
+ expect(restored.painted).toHaveLength(1);
+ expect(restored.painted[0].configJson).toEqual({ sku: 'paint-001' });
+});
+
+test('runtime config defaults', () => {
+ const runtime = createRuntimeConfig();
+ expect(runtime.pipeline.maxImageLongSide).toBe(720);
+ expect(runtime.paint.palette).toHaveLength(6);
+ expect(runtime.mask.semanticColors.length).toBeGreaterThan(0);
+});
+
+test('renders app shell', async () => {
+ const React = require('react');
+ const ReactTestRenderer = require('react-test-renderer');
+ const App = require('../App').default;
+
+ await ReactTestRenderer.act(async () => {
+ ReactTestRenderer.create(React.createElement(App));
+ await Promise.resolve();
});
});
diff --git a/__tests__/paintShader.test.ts b/__tests__/paintShader.test.ts
new file mode 100644
index 0000000..20706c6
--- /dev/null
+++ b/__tests__/paintShader.test.ts
@@ -0,0 +1,79 @@
+jest.mock('../src/utils/opencvAdapter', () => ({
+ __esModule: true,
+ default: {},
+}));
+
+import {
+ buildPaintColorMapImage,
+} from '../src/utils/paintColorMapTexture';
+import {
+ bgrBufferToRgbaBuffer,
+ bgrToLabL,
+ prepareFreqLayersFromBgrBuffer,
+} from '../src/utils/freqLayerPrep';
+import { getRegionPaintEffect } from '../src/utils/paintShaderRuntime';
+
+jest.mock('@shopify/react-native-skia', () => {
+ const makeImage = jest.fn(() => ({
+ dispose: jest.fn(),
+ width: () => 2,
+ height: () => 2,
+ }));
+ return {
+ Skia: {
+ Data: { fromBytes: jest.fn(() => ({})) },
+ Image: { MakeImage: makeImage },
+ RuntimeEffect: {
+ Make: jest.fn(() => ({
+ getUniformCount: () => 4,
+ getUniformName: (i: number) =>
+ ['colorBaseOpacity', 'lLightOpacity', 'textureOpacity', 'showOrigin'][
+ i
+ ],
+ })),
+ },
+ },
+ AlphaType: { Opaque: 1, Unpremul: 3 },
+ ColorType: { RGBA_8888: 4 },
+ TileMode: { Clamp: 0 },
+ ImageFormat: { PNG: 4 },
+ Canvas: 'Canvas',
+ Fill: 'Fill',
+ Shader: 'Shader',
+ ImageShader: 'ImageShader',
+ Group: 'Group',
+ drawAsImage: jest.fn(),
+ };
+});
+
+test('bgrToLabL returns clamped 0-255', () => {
+ expect(bgrToLabL(0, 0, 0)).toBeGreaterThanOrEqual(0);
+ expect(bgrToLabL(255, 255, 255)).toBeLessThanOrEqual(255);
+ expect(bgrToLabL(128, 128, 128)).toBeGreaterThan(0);
+});
+
+test('bgrBufferToRgbaBuffer swaps BGR to RGBA', () => {
+ const bgr = new Uint8Array([10, 20, 30]);
+ const rgba = bgrBufferToRgbaBuffer(bgr, 1, 1);
+ expect(Array.from(rgba)).toEqual([30, 20, 10, 255]);
+});
+
+test('buildPaintColorMapImage marks painted pixels', () => {
+ const pick = new Uint8Array([0, 1, 2]);
+ const painted = new Map([
+ [0, { b: 1, g: 2, r: 3 }],
+ [1, { b: 4, g: 5, r: 6 }],
+ ]);
+ const image = buildPaintColorMapImage(pick, 3, 1, painted);
+ expect(image).toBeTruthy();
+ expect(require('@shopify/react-native-skia').Skia.Image.MakeImage).toHaveBeenCalled();
+});
+
+test('prepareFreqLayersFromBgrBuffer rejects invalid buffer size', async () => {
+ await expect(prepareFreqLayersFromBgrBuffer(new Uint8Array(2), 1, 1)).resolves.toBeNull();
+});
+
+test('regionPaint SkSL compiles via RuntimeEffect', () => {
+ const effect = getRegionPaintEffect();
+ expect(effect.getUniformCount()).toBe(4);
+});
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e189252..acfc82d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,8 @@
+
+
>;
+export default MaskSegmentCanvas;
+//# sourceMappingURL=MaskSegmentCanvas.d.ts.map
\ No newline at end of file
diff --git a/dist/components/MaskSegmentCanvas.d.ts.map b/dist/components/MaskSegmentCanvas.d.ts.map
new file mode 100644
index 0000000..3d02f8a
--- /dev/null
+++ b/dist/components/MaskSegmentCanvas.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAmDf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA4ZnC,QAAA,MAAM,iBAAiB,qGA2pErB,CAAC;AA8HH,eAAe,iBAAiB,CAAC"}
\ No newline at end of file
diff --git a/dist/components/MaskSegmentCanvas.js b/dist/components/MaskSegmentCanvas.js
new file mode 100644
index 0000000..dec8498
--- /dev/null
+++ b/dist/components/MaskSegmentCanvas.js
@@ -0,0 +1,2012 @@
+import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
+import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle, } from 'react';
+import { View, StyleSheet, Button, Dimensions, Text, TouchableOpacity, ScrollView, } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import { runOnJS } from 'react-native-reanimated';
+import { launchImageLibrary } from 'react-native-image-picker';
+import cv from '../utils/opencvAdapter';
+import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation';
+import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage';
+import { resolveImageUrl } from '../utils/resolveImageUrl';
+import { compositePaintedImage } from '../utils/compositePaintedImage';
+import { paintedRegionsFingerprint, resolveExportResultForDestDir, } from '../utils/exportUtils';
+import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '../utils/freqLayerPrep';
+import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
+import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime';
+import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia';
+/* ==========================================================================
+ * 配置常量(屏幕相关;其余见 maskSegmentRuntime)
+ * ========================================================================== */
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+function bgrColorEquals(a, b) {
+ return a.b === b.b && a.g === b.g && a.r === b.r;
+}
+/* ==========================================================================
+ * 几何工具
+ * ========================================================================== */
+function rectsEqual(a, b) {
+ return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
+}
+function getContainRect(canvasW, canvasH, imgW, imgH) {
+ const imgAspect = imgW / imgH;
+ const canvasAspect = canvasW / canvasH;
+ if (imgAspect > canvasAspect) {
+ const w = canvasW;
+ const h = canvasW / imgAspect;
+ return { x: 0, y: (canvasH - h) / 2, w, h };
+ }
+ const h = canvasH;
+ const w = canvasH * imgAspect;
+ return { x: (canvasW - w) / 2, y: 0, w, h };
+}
+function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
+ const rect = getContainRect(canvasW, canvasH, imgW, imgH);
+ if (cx < rect.x ||
+ cx > rect.x + rect.w ||
+ cy < rect.y ||
+ cy > rect.y + rect.h) {
+ return null;
+ }
+ return {
+ x: (cx - rect.x) / rect.w,
+ y: (cy - rect.y) / rect.h,
+ };
+}
+/**
+ * Inverse of the Skia Group transform applied during pinch-zoom.
+ * Converts a raw touch point (screen pixels) back to the canvas coordinate
+ * space where the image and regions are positioned before any scale/pan.
+ * When zoomScale ≤ 1 (no zoom), returns the input unchanged.
+ */
+function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
+ if (zoomScale <= 1)
+ return { x: screenX, y: screenY };
+ // Reverse: translate(-pan) → unscale around center → translate(+center)
+ return {
+ x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
+ y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
+ };
+}
+function pointInPolygon(x, y, points) {
+ let inside = false;
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
+ const xi = points[i].x;
+ const yi = points[i].y;
+ const xj = points[j].x;
+ const yj = points[j].y;
+ const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
+ if (intersect)
+ inside = !inside;
+ }
+ return inside;
+}
+function pointInPolygonWithPadding(x, y, points, padding) {
+ if (points.length < 3) {
+ return false;
+ }
+ let minX = points[0].x;
+ let maxX = points[0].x;
+ let minY = points[0].y;
+ let maxY = points[0].y;
+ for (const point of points) {
+ minX = Math.min(minX, point.x);
+ maxX = Math.max(maxX, point.x);
+ minY = Math.min(minY, point.y);
+ maxY = Math.max(maxY, point.y);
+ }
+ if (x >= minX - padding &&
+ x <= maxX + padding &&
+ y >= minY - padding &&
+ y <= maxY + padding) {
+ if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
+ return true;
+ }
+ }
+ return pointInPolygon(x, y, points);
+}
+function getRegionHitPolygons(reg) {
+ return reg.hitPolygons && reg.hitPolygons.length > 0
+ ? reg.hitPolygons
+ : reg.polygons;
+}
+function pointHitsRegion(x, y, reg, options) {
+ const interaction = getMaskSegmentRuntimeConfig().interaction;
+ const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
+ const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
+ return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
+}
+function pointStrictlyHitsRegion(x, y, reg) {
+ return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
+}
+function resolveRegionHit(regions, x, y) {
+ const hits = [];
+ for (const reg of regions) {
+ const bboxPad = reg.thinStrip ? 0.005 : 0;
+ const b = reg.bbox;
+ if (x < b.x - bboxPad ||
+ x > b.x + b.w + bboxPad ||
+ y < b.y - bboxPad ||
+ y > b.y + b.h + bboxPad) {
+ continue;
+ }
+ if (pointHitsRegion(x, y, reg)) {
+ hits.push(reg);
+ }
+ }
+ if (hits.length === 0) {
+ return null;
+ }
+ if (hits.length === 1) {
+ return hits[0].id;
+ }
+ const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
+ if (strictNonThin.length > 0) {
+ strictNonThin.sort((a, b) => a.area - b.area);
+ return strictNonThin[0].id;
+ }
+ const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
+ if (strictThin.length > 0) {
+ strictThin.sort((a, b) => a.area - b.area);
+ return strictThin[0].id;
+ }
+ const nonThin = hits.filter(reg => !reg.thinStrip);
+ if (nonThin.length > 0) {
+ nonThin.sort((a, b) => a.area - b.area);
+ return nonThin[0].id;
+ }
+ hits.sort((a, b) => a.area - b.area);
+ return hits[0].id;
+}
+function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
+ const cx = Math.floor(normX * pick.cols);
+ const cy = Math.floor(normY * pick.rows);
+ if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
+ return null;
+ }
+ if (strict) {
+ if (baseboardPickMask) {
+ return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
+ }
+ return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
+ ? kickRegionId
+ : null;
+ }
+ const interaction = getMaskSegmentRuntimeConfig().interaction;
+ const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
+ const radiusSq = radius * radius;
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (dx * dx + dy * dy > radiusSq) {
+ continue;
+ }
+ const x = cx + dx;
+ const y = cy + dy;
+ if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
+ continue;
+ }
+ if (baseboardPickMask) {
+ if (baseboardPickMask[y * pick.cols + x]) {
+ return kickRegionId;
+ }
+ continue;
+ }
+ if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
+ return kickRegionId;
+ }
+ }
+ }
+ return null;
+}
+function pickKickNearStrip(normX, normY, kickReg) {
+ const polys = kickReg.hitPolygons ?? kickReg.polygons;
+ const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
+ return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
+}
+function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
+ const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
+ const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
+ const readCode = (x, y) => pick.buffer[y * pick.cols + x];
+ const center = readCode(cx, cy);
+ if (center > 0) {
+ return center - 1;
+ }
+ if (radiusPx <= 0) {
+ return null;
+ }
+ const r = Math.max(4, radiusPx);
+ const rSq = r * r;
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (dx * dx + dy * dy > rSq) {
+ continue;
+ }
+ const x = cx + dx;
+ const y = cy + dy;
+ if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
+ continue;
+ }
+ const code = readCode(x, y);
+ if (code > 0) {
+ return code - 1;
+ }
+ }
+ }
+ return null;
+}
+/** BGR → 屏幕 RGB */
+function bgrToCss(b, g, r) {
+ return `rgb(${r},${g},${b})`;
+}
+function releasePaintResourceLayers(layers) {
+ if (!layers) {
+ return;
+ }
+ layers.lowFreqImage.dispose();
+ layers.highFreqImage.dispose();
+}
+function releaseOriginSkImage(image) {
+ if (image) {
+ image.dispose();
+ }
+}
+async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
+ if (workScale >= 1) {
+ return { buffer: bgrBuffer, cols, rows };
+ }
+ const workCols = Math.floor(cols * workScale);
+ const workRows = Math.floor(rows * workScale);
+ const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
+ return { buffer, cols: workCols, rows: workRows };
+}
+/* ==========================================================================
+ * 分段计时工具(仅开发环境生效)
+ * ========================================================================== */
+let _timeLogTs = 0;
+function timeLog(tag) {
+ if (!__DEV__)
+ return;
+ const now = performance.now();
+ const dt = _timeLogTs ? now - _timeLogTs : 0;
+ console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
+ _timeLogTs = now;
+}
+/* ==========================================================================
+ * 组件主体
+ * ========================================================================== */
+const MaskSegmentCanvas = forwardRef(function MaskSegmentCanvas(props, ref) {
+ const { originUrl: originUrlProp, maskUrl: maskUrlProp, originImgPath: originImgPathLegacy, maskImgPath: maskImgPathLegacy, maskConfig, pipelinePreset, pipelineConfig, paintConfig, interactionConfig, semanticColors, regionOutlineColor, initialSession, initialPaintColor, initialPaintConfigJson, showDebugPickers = true, showToolbar = true, showColorBar = true, showStatusRow = true, showOverlayButtons = true, disabled = false, style, canvasStyle, maxHeight, undoButtonStyle, compareButtonStyle, undoButtonTextStyle, compareButtonTextStyle, undoButtonText = '撤销', compareButtonText = '对比原图', compareExitButtonText = '退出对比', renderUndoButton, renderCompareButton, onWatch, onPaintCallback, onError, autoExportOnReady, onExported, } = props;
+ const originSource = originUrlProp ?? originImgPathLegacy ?? '';
+ const maskSource = maskUrlProp ?? maskImgPathLegacy ?? '';
+ const resolvedMaskConfig = useMemo(() => semanticColors
+ ? { ...maskConfig, semanticColors }
+ : maskConfig, [maskConfig, semanticColors]);
+ const resolvedPaintConfig = useMemo(() => regionOutlineColor
+ ? { ...paintConfig, regionOverlayFill: regionOutlineColor }
+ : paintConfig, [paintConfig, regionOutlineColor]);
+ const [resolvedOriginPath, setResolvedOriginPath] = useState('');
+ const [resolvedMaskPath, setResolvedMaskPath] = useState('');
+ const [originImgPath, setOriginImgPath] = useState(resolvedOriginPath);
+ const [maskImgPath, setMaskImgPath] = useState(resolvedMaskPath);
+ // Latest desired image paths (updated when the internal path states settle).
+ // Used by segmentAndPrepareLayers to decide whether a stale runId (from effect cleanup due to
+ // unrelated parent re-renders) should actually abort the current async read/segment work.
+ // If the image pair we are processing is still the one the caller ultimately wants, we continue.
+ const latestOriginPathRef = useRef('');
+ const latestMaskPathRef = useRef('');
+ const resolvedPipelineConfig = useMemo(() => resolvePipelineConfig(pipelinePreset, pipelineConfig), [pipelinePreset, pipelineConfig]);
+ const runtimeRef = useRef(createRuntimeConfig({
+ maskConfig: resolvedMaskConfig,
+ pipelineConfig: resolvedPipelineConfig,
+ paintConfig: resolvedPaintConfig,
+ interactionConfig,
+ }));
+ // Track last *values* we pushed for paintConfig. We only call the global setMaskSegmentRuntimeConfig
+ // (which bumps runtimeRevision) when the actual numbers change. This prevents repeated parent
+ // re-renders that pass a new object literal with identical values from causing:
+ // - global revision bump
+ // - paintColorMap useMemo invalidation (full-res Uint8Array + boxBlur + Skia image alloc)
+ // - extra main-thread work during the critical segmentation / freq-layers / outline paths window.
+ // The local runtimeRef is still kept in sync for any synchronous readers.
+ const lastAppliedPaintConfigRef = useRef(null);
+ const lastAppliedPipelineSignatureRef = useRef(null);
+ useEffect(() => {
+ const prevPaint = lastAppliedPaintConfigRef.current;
+ const currPaint = resolvedPaintConfig || {};
+ const paintKeys = [
+ 'colorBaseOpacity',
+ 'lLightOpacity',
+ 'textureOpacity',
+ 'lLowBlurKernel',
+ 'lLowContrast',
+ 'lLowBrightness',
+ 'lHighGain',
+ 'maskFeatherColor',
+ 'maskFeatherTexture',
+ 'regionOverlayFill',
+ ];
+ const paintChanged = !prevPaint || paintKeys.some((k) => currPaint[k] !== prevPaint[k]);
+ const pipelineSignature = JSON.stringify(resolvedPipelineConfig);
+ const pipelineChanged = lastAppliedPipelineSignatureRef.current !== pipelineSignature;
+ if (paintChanged || pipelineChanged) {
+ if (paintChanged) {
+ lastAppliedPaintConfigRef.current = { ...currPaint };
+ }
+ if (pipelineChanged) {
+ lastAppliedPipelineSignatureRef.current = pipelineSignature;
+ }
+ runtimeRef.current = setMaskSegmentRuntimeConfig({
+ maskConfig: resolvedMaskConfig,
+ pipelineConfig: resolvedPipelineConfig,
+ paintConfig: resolvedPaintConfig,
+ interactionConfig,
+ });
+ }
+ }, [
+ resolvedMaskConfig,
+ resolvedPipelineConfig,
+ resolvedPaintConfig,
+ interactionConfig,
+ ]);
+ const paintPalette = runtimeRef.current.paint.palette;
+ const paintRuntime = getMaskSegmentRuntimeConfig().paint;
+ const interactionRuntime = getMaskSegmentRuntimeConfig().interaction;
+ const onWatchRef = useRef(onWatch);
+ const onPaintCallbackRef = useRef(onPaintCallback);
+ const onErrorRef = useRef(onError);
+ const onExportedRef = useRef(onExported);
+ useEffect(() => {
+ onWatchRef.current = onWatch;
+ onPaintCallbackRef.current = onPaintCallback;
+ onErrorRef.current = onError;
+ onExportedRef.current = onExported;
+ }, [onWatch, onPaintCallback, onError, onExported]);
+ const watchStartRef = useRef(0);
+ const lastWatchStateRef = useRef(null);
+ const lastWatchSignatureRef = useRef(null);
+ const emitWatch = useCallback((state, detail) => {
+ const signature = [
+ state,
+ detail?.regionCount ?? '',
+ detail?.maskPathsReady ?? '',
+ detail?.freqLayersReady ?? '',
+ detail?.errorMessage ?? '',
+ ].join('|');
+ if (lastWatchSignatureRef.current === signature) {
+ return;
+ }
+ lastWatchSignatureRef.current = signature;
+ lastWatchStateRef.current = state;
+ const durationMs = watchStartRef.current
+ ? performance.now() - watchStartRef.current
+ : 0;
+ onWatchRef.current?.(state, durationMs, detail);
+ }, []);
+ const reportError = useCallback((message, error) => {
+ emitWatch('error', { errorMessage: message });
+ if (onErrorRef.current) {
+ onErrorRef.current(message, error);
+ }
+ else if (__DEV__) {
+ console.error('[MaskSegment]', message, error);
+ }
+ }, [emitWatch]);
+ const [customPaintColor, setCustomPaintColor] = useState(initialPaintColor ?? null);
+ const customPaintConfigJsonRef = useRef(initialPaintConfigJson);
+ const originUrlRef = useRef(originSource);
+ const maskUrlRef = useRef(maskSource);
+ useEffect(() => {
+ originUrlRef.current = originSource;
+ maskUrlRef.current = maskSource;
+ }, [originSource, maskSource]);
+ useEffect(() => {
+ let cancelled = false;
+ if (!originSource || !maskSource) {
+ setResolvedOriginPath('');
+ setResolvedMaskPath('');
+ return;
+ }
+ void (async () => {
+ try {
+ const [originPath, maskPath] = await Promise.all([
+ resolveImageUrl(originSource, 'origin.png'),
+ resolveImageUrl(maskSource, 'mask.png'),
+ ]);
+ if (!cancelled) {
+ setResolvedOriginPath(originPath);
+ setResolvedMaskPath(maskPath);
+ }
+ }
+ catch (e) {
+ if (!cancelled) {
+ const msg = e instanceof Error ? e.message : String(e);
+ reportError(msg, e);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [originSource, maskSource, reportError]);
+ useEffect(() => {
+ setOriginImgPath(resolvedOriginPath);
+ setMaskImgPath(resolvedMaskPath);
+ if (resolvedOriginPath && resolvedMaskPath) {
+ prewarmPngBgrCache([resolvedOriginPath, resolvedMaskPath]);
+ }
+ }, [resolvedOriginPath, resolvedMaskPath]);
+ // Keep latest desired paths for cancellation decisions inside the long async segment pipeline.
+ useEffect(() => {
+ latestOriginPathRef.current = originImgPath || '';
+ latestMaskPathRef.current = maskImgPath || '';
+ }, [originImgPath, maskImgPath]);
+ const [paintResourceLayers, setPaintResourceLayers] = useState(null);
+ const paintResourceLayersRef = useRef(null);
+ const paintColorMapSkImgRef = useRef(null);
+ const [activeBrushIndex, setActiveBrushIndex] = useState(null);
+ const [paintedRegions, setPaintedRegions] = useState(() => new Map());
+ const paintedRegionsRef = useRef(new Map());
+ const [paintHistory, setPaintHistory] = useState([]);
+ // Seed the ref with the initial empty map so early reads (before any paint effect)
+ // are consistent.
+ paintedRegionsRef.current = paintedRegions;
+ const [heldRegionId, setHeldRegionId] = useState(null);
+ const [heldRegionAnchor, setHeldRegionAnchor] = useState(null);
+ const [initFlashRegionId, setInitFlashRegionId] = useState(null);
+ const initFlashTimerRef = useRef(null);
+ const initFlashIndexRef = useRef(0);
+ const initFlashActiveRef = useRef(false);
+ // List of regions still eligible for the init dashed-outline flash (discovery aid).
+ // Computed at the start of the flash loop, excluding any that are already painted.
+ // This ensures that on continue-edit (or any partial seed), already-colored regions
+ // do not get the flashing dashed outline.
+ const initFlashListRef = useRef([]);
+ // Guard so that initialSession (seed from host bootstrap or scheme) is applied only once
+ // after segmentsReady. Prevents later prop identity changes (e.g. caused by host slot/brush
+ // selection re-renders) from calling restoreSession again, which would clobber live
+ // paintedRegions with a re-derived snapshot (often causing already-painted regions to
+ // "follow" the newly selected brush color).
+ const hasAppliedInitialSessionRef = useRef(false);
+ // Keep a ref to the absolute latest paintedRegions so that imperative save()
+ // (called from host performSaveProject for new schemes, or manually) and
+ // internal composites always see the most up-to-date painted state, even
+ // if the useImperativeHandle closure was created in a prior render.
+ // This fixes cases where the last user paint's colors were missing from
+ // the recolored After image captured at "save scheme" time.
+ useEffect(() => {
+ paintedRegionsRef.current = paintedRegions;
+ }, [paintedRegions]);
+ // Cached export from the most recent auto-export or save() — keyed by painted fingerprint.
+ const lastExportCacheRef = useRef(null);
+ const autoExportDebounceRef = useRef(null);
+ const exportInFlightRef = useRef(false);
+ const regionsRef = useRef([]);
+ const maskPickRef = useRef(null);
+ const regionPickRef = useRef(null);
+ const regionMaskDataRef = useRef(null);
+ const workBufferRef = useRef(null);
+ const paintLayersPromiseRef = useRef(null);
+ const loadPaintLayersRef = useRef(() => Promise.resolve());
+ const [paintResourcesReady, setPaintResourcesReady] = useState(false);
+ const [layersLoading, setLayersLoading] = useState(false);
+ const [maskPathsReady, setMaskPathsReady] = useState(false);
+ const baseboardPickMaskRef = useRef(null);
+ const kickRegionIdRef = useRef(null);
+ const [regionPalette, setRegionPalette] = useState([]);
+ const [regionCount, setRegionCount] = useState(0);
+ const [imageSize, setImageSize] = useState(null);
+ // High-resolution offscreen Canvas (sized to the work buffer resolution) whose content
+ // is the full shader composition (PaintShaderLayer at 0,0,workW,workH). On save() we
+ // call makeImageSnapshot() on it to get a "what you see in the editor, at source res"
+ // PNG bytes. This is the preferred "保存快照" path and avoids CPU recolor entirely
+ // for the exported After.
+ const highResExportCanvasRef = useCanvasRef();
+ const [exportCanvasSize, setExportCanvasSize] = useState(null);
+ // Gate the (potentially expensive) high-res snapshot canvas so it is only mounted
+ // after the user (or initialSession seed) has painted at least one region. This keeps
+ // idle segmentation / no-paint cases cheap.
+ const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
+ // Layout measurement for the root container of this component. Declared early so
+ // the canvasW/canvasH memos (which decide the viewport rect for zoom centering,
+ // containRect placement, clipping, and gesture coordinate mapping) can close over it.
+ // When the host passes a fitted frame (VisualizationScreen's canvasFrame with explicit
+ // w/h derived from safe area + aspect, or scheme cards), we size our internal Skia
+ // canvas + gesture layer + zoom transform to that exact allocated rect.
+ const [layoutWidth, setLayoutWidth] = useState(null);
+ const [layoutHeight, setLayoutHeight] = useState(null);
+ const [segmentsReady, setSegmentsReady] = useState(false);
+ const segmentsReadyRef = useRef(false);
+ const maskPathsReadyRef = useRef(false);
+ const [canvasInteractive, setCanvasInteractive] = useState(false);
+ const [segError, setSegError] = useState('');
+ const [compareMode, setCompareMode] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [originSkImg, setOriginSkImg] = useState(null);
+ const originSkImgRef = useRef(null);
+ const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
+ const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
+ const canvasBaseW = SCREEN_WIDTH - 20;
+ // The "viewport" size for this canvas component: the rect inside which we place
+ // the contained image, apply the zoom Group transform (centered), clip, and receive
+ // gestures (tap + two-finger pinch). When we have a real onLayout from the host
+ // (VisualizationScreen's aspect-fitted canvasFrame, or scheme card preview area),
+ // we use the *allocated pixel size* directly as our viewport.
+ //
+ // Accurate canvasW/H is still critical for:
+ // - Correct centering of the scaled content around the viewport center.
+ // - Proper containRect (letterbox/centering of the source photo inside the viewport).
+ // - Clip rect and gesture-to-canvas coordinate conversion used by painting.
+ //
+ // Previously the code fell back to aspect-derived sizes even after layout, which
+ // could cause the effective viewport to not match the host frame. Using the onLayout
+ // result (with maxHeight fallback only when no layout yet) keeps zoom, clip, and
+ // touch mapping consistent with what the user actually sees and touches.
+ const viewportW = useMemo(() => {
+ if (layoutWidth != null && layoutHeight != null) {
+ // Primary path for viz screen and scheme cards: the exact size the host
+ // decided for this component (after its own safe-area + aspect fit).
+ return layoutWidth;
+ }
+ if (!maxHeight || maxHeight <= 0) {
+ return canvasBaseW;
+ }
+ // Fallback (no layout yet, or other usages that pass maxHeight without a
+ // tightly sized parent frame). Replicate a contain-style budget.
+ const availableW = canvasBaseW;
+ let auxHeight = 0;
+ if (showToolbar)
+ auxHeight += 40;
+ if (showStatusRow)
+ auxHeight += 30;
+ if (showColorBar)
+ auxHeight += 70;
+ const availableH = Math.max(100, maxHeight - 20 - auxHeight);
+ const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
+ const containerAspect = availableW / availableH;
+ if (containerAspect > imgAspect) {
+ return Math.floor(availableH * imgAspect);
+ }
+ return availableW;
+ }, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, canvasBaseW, imageSize]);
+ const viewportH = useMemo(() => {
+ if (layoutWidth != null && layoutHeight != null) {
+ return layoutHeight;
+ }
+ if (!maxHeight || maxHeight <= 0) {
+ const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
+ return Math.floor(viewportW / imgAspect);
+ }
+ let auxHeight = 0;
+ if (showToolbar)
+ auxHeight += 40;
+ if (showStatusRow)
+ auxHeight += 30;
+ if (showColorBar)
+ auxHeight += 70;
+ return Math.max(100, maxHeight - 20 - auxHeight);
+ }, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, viewportW, imageSize]);
+ // For the rest of the component, "canvasW/H" means the viewport rect size.
+ // All zoom center, wrap size, touch layer, Canvas size, clip, containRect, and
+ // gesture coordinate mapping are based on this, so that two-finger zoom centering
+ // and single-finger tap painting stay consistent with the host-allocated area.
+ const canvasW = viewportW;
+ const canvasH = viewportH;
+ // Refs synced to the latest viewport size so that async callbacks
+ // (segmentAndPrepareLayers) always read post-layout values instead of
+ // stale closure captures. This fixes dashed-outline offset to the bottom
+ // when the initial pathMapRect was computed with fallback SCREEN_WIDTH.
+ // Declared before segmentAndPrepareLayers so the async body can reference them.
+ const canvasWRef = useRef(canvasW);
+ const canvasHRef = useRef(canvasH);
+ // ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ─────────
+ const [zoomScale, setZoomScale] = useState(1);
+ const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+ // Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
+ const zoomScaleRef = useRef(1);
+ const panOffsetRef = useRef({ x: 0, y: 0 });
+ // Baseline value captured at gesture start to avoid jump on re-creation for pinch
+ const zoomBaseRef = useRef(1);
+ // Ref to the latest containRect (the actual placed photo rect inside the viewport).
+ const containRectRef = useRef(null);
+ useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
+ useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
+ const resetZoom = useCallback(() => {
+ setZoomScale(1);
+ setPanOffset({ x: 0, y: 0 });
+ zoomScaleRef.current = 1;
+ panOffsetRef.current = { x: 0, y: 0 };
+ }, []);
+ const containRect = useMemo(() => {
+ if (!imageSize)
+ return null;
+ return getContainRect(canvasW, canvasH, imageSize.w, imageSize.h);
+ }, [imageSize, canvasW, canvasH]);
+ // Keep a ref in sync so early-defined callbacks can read the
+ // latest containRect without TDZ or stale closure issues.
+ useEffect(() => {
+ containRectRef.current = containRect;
+ }, [containRect]);
+ const segmentRunIdRef = useRef(0);
+ const lastSegmentKeyRef = useRef('');
+ const segmentInFlightKeyRef = useRef('');
+ const maskPathsContainRectRef = useRef(null);
+ useEffect(() => {
+ paintResourceLayersRef.current = paintResourceLayers;
+ }, [paintResourceLayers]);
+ useEffect(() => {
+ segmentsReadyRef.current = segmentsReady;
+ }, [segmentsReady]);
+ useEffect(() => {
+ maskPathsReadyRef.current = maskPathsReady;
+ }, [maskPathsReady]);
+ const emitLayersReadyIfReady = useCallback(() => {
+ if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
+ return;
+ }
+ emitWatch('layers_ready', {
+ regionCount: regionsRef.current.length,
+ maskPathsReady: maskPathsReadyRef.current,
+ freqLayersReady: true,
+ });
+ }, [emitWatch]);
+ const emitMaskPathsReadyIfReady = useCallback(() => {
+ if (!segmentsReadyRef.current || !maskPathsReadyRef.current) {
+ return;
+ }
+ emitWatch('mask_paths_ready', {
+ regionCount: regionsRef.current.length,
+ maskPathsReady: true,
+ freqLayersReady: true,
+ });
+ }, [emitWatch]);
+ const emitInteractiveIfReady = useCallback(() => {
+ if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
+ return;
+ }
+ emitLayersReadyIfReady();
+ emitWatch('interactive', {
+ regionCount: regionsRef.current.length,
+ maskPathsReady: maskPathsReadyRef.current,
+ freqLayersReady: true,
+ });
+ setCanvasInteractive(true);
+ }, [emitWatch, emitLayersReadyIfReady]);
+ const paintColorMapSkImg = useMemo(() => {
+ const pick = regionPickRef.current;
+ paintColorMapSkImgRef.current?.dispose();
+ // Early out: no pick buffer yet, or no regions have been painted (initial load or fresh session).
+ // Avoids repeated full-resolution RGBA allocation + boxBlur (for maskFeather) + Skia.Image.MakeImage
+ // on every re-render during the hot init path. When initialSession restores paints, paintedRegions
+ // will update and legitimately trigger a single build of the (feathered) color map.
+ if (!pick || paintedRegions.size === 0) {
+ paintColorMapSkImgRef.current = null;
+ return null;
+ }
+ const map = createPaintColorMapForPaint(pick.buffer, pick.cols, pick.rows, paintedRegions);
+ paintColorMapSkImgRef.current = map;
+ return map;
+ }, [paintedRegions, paintResourcesReady, segmentsReady, getMaskRuntimeRevision()]);
+ const paintedRegionConfigRef = useRef(new Map());
+ const segmentAndPrepareLayers = useCallback(async (originPath, maskPath) => {
+ const runId = ++segmentRunIdRef.current;
+ // isCancelled: a runId bump alone is not enough to abort if the image pair we were asked to process
+ // is still the latest desired pair from the caller (protects against effect cleanups caused by
+ // unrelated parent re-renders / state updates that do not change the two images).
+ const isCancelled = () => {
+ if (runId === segmentRunIdRef.current)
+ return false;
+ const desiredOrigin = latestOriginPathRef.current || originPath;
+ const desiredMask = latestMaskPathRef.current || maskPath;
+ const stillWanted = desiredOrigin === originPath && desiredMask === maskPath;
+ return !stillWanted;
+ };
+ const pipeline = getMaskSegmentRuntimeConfig().pipeline;
+ watchStartRef.current = performance.now();
+ lastWatchStateRef.current = null;
+ lastWatchSignatureRef.current = null;
+ emitWatch('init');
+ timeLog('▶ start segmentation');
+ segmentsReadyRef.current = false;
+ setSegmentsReady(false);
+ setSegError('');
+ setActiveBrushIndex(null);
+ const clearedPainted = new Map();
+ paintedRegionsRef.current = clearedPainted;
+ setPaintedRegions(clearedPainted);
+ setPaintHistory([]);
+ setHeldRegionId(null);
+ setHeldRegionAnchor(null);
+ setInitFlashRegionId(null);
+ initFlashActiveRef.current = false;
+ initFlashIndexRef.current = 0;
+ initFlashListRef.current = [];
+ if (initFlashTimerRef.current) {
+ clearTimeout(initFlashTimerRef.current);
+ initFlashTimerRef.current = null;
+ }
+ hasAppliedInitialSessionRef.current = false;
+ lastExportCacheRef.current = null;
+ if (autoExportDebounceRef.current) {
+ clearTimeout(autoExportDebounceRef.current);
+ autoExportDebounceRef.current = null;
+ }
+ exportInFlightRef.current = false;
+ regionsRef.current = [];
+ maskPickRef.current = null;
+ regionPickRef.current = null;
+ regionMaskDataRef.current = null;
+ workBufferRef.current = null;
+ paintLayersPromiseRef.current = null;
+ setRegionOutlinePaths(new Map());
+ setMaskPathsReady(false);
+ setPaintResourcesReady(false);
+ setLayersLoading(false);
+ baseboardPickMaskRef.current = null;
+ kickRegionIdRef.current = null;
+ maskPathsContainRectRef.current = null;
+ setRegionPalette([]);
+ setRegionCount(0);
+ // Reset high-res snapshot state so a fresh segmentation starts clean (no stale size/ref).
+ setExportCanvasSize(null);
+ setHighResSnapshotEnabled(false);
+ const prevLayers = paintResourceLayersRef.current;
+ if (prevLayers) {
+ releasePaintResourceLayers(prevLayers);
+ paintResourceLayersRef.current = null;
+ setPaintResourceLayers(null);
+ }
+ releaseOriginSkImage(originSkImgRef.current);
+ originSkImgRef.current = null;
+ setOriginSkImg(null);
+ try {
+ timeLog('▶ reading origin PNG');
+ const originPromise = readPngBgrBuffer(originPath);
+ timeLog('▶ reading mask PNG');
+ const maskPromise = readPngBgrBuffer(maskPath);
+ const originDecoded = await originPromise;
+ const afterOriginCancelled = isCancelled();
+ if (afterOriginCancelled) {
+ return;
+ }
+ const imgW = originDecoded.cols;
+ const imgH = originDecoded.rows;
+ setImageSize({ w: imgW, h: imgH });
+ let scale = 1;
+ if (Math.max(imgW, imgH) > pipeline.maxImageLongSide) {
+ scale = pipeline.maxImageLongSide / Math.max(imgW, imgH);
+ }
+ const segW = Math.floor(imgW * scale);
+ const segH = Math.floor(imgH * scale);
+ const minArea = pipeline.minContourArea * scale * scale;
+ const workScaledTask = prepareWorkScaledBgrBuffer(originDecoded.buffer, imgW, imgH, scale).then(workScaled => {
+ if (isCancelled()) {
+ return workScaled;
+ }
+ workBufferRef.current = workScaled;
+ setExportCanvasSize({ w: workScaled.cols, h: workScaled.rows });
+ // Enable the offscreen high-res export canvas as soon as we know the work resolution.
+ // This gives the hidden