react-native-mask-segment-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +904 -0
- package/dist/components/MaskSegmentCanvas.d.ts +6 -0
- package/dist/components/MaskSegmentCanvas.d.ts.map +1 -0
- package/dist/components/MaskSegmentCanvas.js +2012 -0
- package/dist/components/MaskSegmentCanvas.js.map +1 -0
- package/dist/components/MaskSegmentCanvas.types.d.ts +189 -0
- package/dist/components/MaskSegmentCanvas.types.d.ts.map +1 -0
- package/dist/components/MaskSegmentCanvas.types.js +2 -0
- package/dist/components/MaskSegmentCanvas.types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/shaders/regionPaint.sksl.d.ts +3 -0
- package/dist/shaders/regionPaint.sksl.d.ts.map +1 -0
- package/dist/shaders/regionPaint.sksl.js +72 -0
- package/dist/shaders/regionPaint.sksl.js.map +1 -0
- package/dist/utils/compositePaintedImage.d.ts +44 -0
- package/dist/utils/compositePaintedImage.d.ts.map +1 -0
- package/dist/utils/compositePaintedImage.js +146 -0
- package/dist/utils/compositePaintedImage.js.map +1 -0
- package/dist/utils/exportUtils.d.ts +20 -0
- package/dist/utils/exportUtils.d.ts.map +1 -0
- package/dist/utils/exportUtils.js +32 -0
- package/dist/utils/exportUtils.js.map +1 -0
- package/dist/utils/freqLayerPrep.d.ts +23 -0
- package/dist/utils/freqLayerPrep.d.ts.map +1 -0
- package/dist/utils/freqLayerPrep.js +168 -0
- package/dist/utils/freqLayerPrep.js.map +1 -0
- package/dist/utils/maskSegmentRuntime.d.ts +43 -0
- package/dist/utils/maskSegmentRuntime.d.ts.map +1 -0
- package/dist/utils/maskSegmentRuntime.js +181 -0
- package/dist/utils/maskSegmentRuntime.js.map +1 -0
- package/dist/utils/maskSegmentation.d.ts +133 -0
- package/dist/utils/maskSegmentation.d.ts.map +1 -0
- package/dist/utils/maskSegmentation.js +1600 -0
- package/dist/utils/maskSegmentation.js.map +1 -0
- package/dist/utils/maskSemanticPalette.d.ts +31 -0
- package/dist/utils/maskSemanticPalette.d.ts.map +1 -0
- package/dist/utils/maskSemanticPalette.js +125 -0
- package/dist/utils/maskSemanticPalette.js.map +1 -0
- package/dist/utils/opencvAdapter.d.ts +116 -0
- package/dist/utils/opencvAdapter.d.ts.map +1 -0
- package/dist/utils/opencvAdapter.js +353 -0
- package/dist/utils/opencvAdapter.js.map +1 -0
- package/dist/utils/paintColorMapTexture.d.ts +5 -0
- package/dist/utils/paintColorMapTexture.d.ts.map +1 -0
- package/dist/utils/paintColorMapTexture.js +203 -0
- package/dist/utils/paintColorMapTexture.js.map +1 -0
- package/dist/utils/paintShaderRuntime.d.ts +40 -0
- package/dist/utils/paintShaderRuntime.d.ts.map +1 -0
- package/dist/utils/paintShaderRuntime.js +76 -0
- package/dist/utils/paintShaderRuntime.js.map +1 -0
- package/dist/utils/pickMapTexture.d.ts +4 -0
- package/dist/utils/pickMapTexture.d.ts.map +1 -0
- package/dist/utils/pickMapTexture.js +24 -0
- package/dist/utils/pickMapTexture.js.map +1 -0
- package/dist/utils/pngImage.d.ts +49 -0
- package/dist/utils/pngImage.d.ts.map +1 -0
- package/dist/utils/pngImage.js +438 -0
- package/dist/utils/pngImage.js.map +1 -0
- package/dist/utils/resolveAssetPath.d.ts +3 -0
- package/dist/utils/resolveAssetPath.d.ts.map +1 -0
- package/dist/utils/resolveAssetPath.js +56 -0
- package/dist/utils/resolveAssetPath.js.map +1 -0
- package/dist/utils/resolveImageUrl.d.ts +3 -0
- package/dist/utils/resolveImageUrl.d.ts.map +1 -0
- package/dist/utils/resolveImageUrl.js +51 -0
- package/dist/utils/resolveImageUrl.js.map +1 -0
- package/dist/utils/skiaImage.d.ts +4 -0
- package/dist/utils/skiaImage.d.ts.map +1 -0
- package/dist/utils/skiaImage.js +12 -0
- package/dist/utils/skiaImage.js.map +1 -0
- package/package.json +100 -0
- package/patches/react-native-fast-opencv+0.4.8.patch +122 -0
- package/src/components/MaskSegmentCanvas.tsx +2832 -0
- package/src/components/MaskSegmentCanvas.types.ts +216 -0
- package/src/globals.d.ts +19 -0
- package/src/index.ts +45 -0
- package/src/shaders/regionPaint.sksl.ts +71 -0
- package/src/upng-js.d.ts +33 -0
- package/src/utils/compositePaintedImage.ts +201 -0
- package/src/utils/exportUtils.ts +40 -0
- package/src/utils/freqLayerPrep.ts +267 -0
- package/src/utils/maskSegmentRuntime.ts +257 -0
- package/src/utils/maskSegmentation.ts +2294 -0
- package/src/utils/maskSemanticPalette.ts +187 -0
- package/src/utils/opencvAdapter.ts +539 -0
- package/src/utils/paintColorMapTexture.ts +239 -0
- package/src/utils/paintShaderRuntime.tsx +150 -0
- package/src/utils/pickMapTexture.ts +37 -0
- package/src/utils/pngImage.ts +591 -0
- package/src/utils/resolveAssetPath.ts +64 -0
- package/src/utils/resolveImageUrl.ts +63 -0
- package/src/utils/skiaImage.ts +25 -0
|
@@ -0,0 +1,2832 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useRef,
|
|
3
|
+
useState,
|
|
4
|
+
useEffect,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
forwardRef,
|
|
8
|
+
useImperativeHandle,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
View,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
Button,
|
|
14
|
+
Dimensions,
|
|
15
|
+
Text,
|
|
16
|
+
TouchableOpacity,
|
|
17
|
+
ScrollView,
|
|
18
|
+
type GestureResponderEvent,
|
|
19
|
+
} from 'react-native';
|
|
20
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
21
|
+
import { runOnJS } from 'react-native-reanimated';
|
|
22
|
+
import { launchImageLibrary } from 'react-native-image-picker';
|
|
23
|
+
import cv from '../utils/opencvAdapter';
|
|
24
|
+
import {
|
|
25
|
+
buildAllRegionOutlinePaths,
|
|
26
|
+
buildRegionOutlinePathForRegion,
|
|
27
|
+
downsampleMaskDataForPaths,
|
|
28
|
+
extractRegionsFromMaskBufferSync,
|
|
29
|
+
isBaseboardMaskPixel,
|
|
30
|
+
upscaleBinaryMask,
|
|
31
|
+
type SegmentRegion,
|
|
32
|
+
} from '../utils/maskSegmentation';
|
|
33
|
+
import {
|
|
34
|
+
clearDerivedImageCache,
|
|
35
|
+
readPngBgrBuffer,
|
|
36
|
+
prewarmPngBgrCache,
|
|
37
|
+
resizeBgrBuffer,
|
|
38
|
+
} from '../utils/pngImage';
|
|
39
|
+
import { resolveImageUrl } from '../utils/resolveImageUrl';
|
|
40
|
+
import { compositePaintedImage } from '../utils/compositePaintedImage';
|
|
41
|
+
import {
|
|
42
|
+
paintedRegionsFingerprint,
|
|
43
|
+
resolveExportResultForDestDir,
|
|
44
|
+
} from '../utils/exportUtils';
|
|
45
|
+
import {
|
|
46
|
+
preparePaintResourcesFromWorkBuffer,
|
|
47
|
+
releaseFreqLayerImages,
|
|
48
|
+
} from '../utils/freqLayerPrep';
|
|
49
|
+
import {
|
|
50
|
+
PaintShaderLayer,
|
|
51
|
+
createPaintColorMapForPaint,
|
|
52
|
+
} from '../utils/paintShaderRuntime';
|
|
53
|
+
import {
|
|
54
|
+
createRuntimeConfig,
|
|
55
|
+
getMaskRuntimeRevision,
|
|
56
|
+
getMaskSegmentRuntimeConfig,
|
|
57
|
+
resolvePipelineConfig,
|
|
58
|
+
setMaskSegmentRuntimeConfig,
|
|
59
|
+
} from '../utils/maskSegmentRuntime';
|
|
60
|
+
import type {
|
|
61
|
+
BgrColor,
|
|
62
|
+
MaskSegmentCanvasProps,
|
|
63
|
+
MaskSegmentCanvasRef,
|
|
64
|
+
MaskSegmentSession,
|
|
65
|
+
MaskSegmentWatchDetail,
|
|
66
|
+
MaskSegmentWatchState,
|
|
67
|
+
PaintedRegionRecord,
|
|
68
|
+
SavePaintResult,
|
|
69
|
+
} from './MaskSegmentCanvas.types';
|
|
70
|
+
import {
|
|
71
|
+
Canvas,
|
|
72
|
+
Image as SkiaImage,
|
|
73
|
+
Path,
|
|
74
|
+
Group,
|
|
75
|
+
DashPathEffect,
|
|
76
|
+
Rect,
|
|
77
|
+
useCanvasRef,
|
|
78
|
+
Skia,
|
|
79
|
+
type SkImage,
|
|
80
|
+
type SkPath,
|
|
81
|
+
} from '@shopify/react-native-skia';
|
|
82
|
+
|
|
83
|
+
export type {
|
|
84
|
+
MaskSegmentCanvasProps,
|
|
85
|
+
MaskSegmentCanvasRef,
|
|
86
|
+
MaskSegmentSession,
|
|
87
|
+
MaskSegmentWatchState,
|
|
88
|
+
PaintedRegionRecord,
|
|
89
|
+
BgrColor,
|
|
90
|
+
MaskSemanticColor,
|
|
91
|
+
} from './MaskSegmentCanvas.types';
|
|
92
|
+
|
|
93
|
+
/* ==========================================================================
|
|
94
|
+
* 配置常量(屏幕相关;其余见 maskSegmentRuntime)
|
|
95
|
+
* ========================================================================== */
|
|
96
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
97
|
+
|
|
98
|
+
/* ==========================================================================
|
|
99
|
+
* 类型
|
|
100
|
+
* ========================================================================== */
|
|
101
|
+
type PaintResourceLayers = {
|
|
102
|
+
lowFreqImage: SkImage;
|
|
103
|
+
highFreqImage: SkImage;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ContainRect = {
|
|
107
|
+
x: number;
|
|
108
|
+
y: number;
|
|
109
|
+
w: number;
|
|
110
|
+
h: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function bgrColorEquals(a: BgrColor, b: BgrColor): boolean {
|
|
114
|
+
return a.b === b.b && a.g === b.g && a.r === b.r;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ==========================================================================
|
|
118
|
+
* 几何工具
|
|
119
|
+
* ========================================================================== */
|
|
120
|
+
function rectsEqual(a: ContainRect, b: ContainRect): boolean {
|
|
121
|
+
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getContainRect(
|
|
125
|
+
canvasW: number,
|
|
126
|
+
canvasH: number,
|
|
127
|
+
imgW: number,
|
|
128
|
+
imgH: number,
|
|
129
|
+
): ContainRect {
|
|
130
|
+
const imgAspect = imgW / imgH;
|
|
131
|
+
const canvasAspect = canvasW / canvasH;
|
|
132
|
+
|
|
133
|
+
if (imgAspect > canvasAspect) {
|
|
134
|
+
const w = canvasW;
|
|
135
|
+
const h = canvasW / imgAspect;
|
|
136
|
+
return { x: 0, y: (canvasH - h) / 2, w, h };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const h = canvasH;
|
|
140
|
+
const w = canvasH * imgAspect;
|
|
141
|
+
return { x: (canvasW - w) / 2, y: 0, w, h };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function canvasToNormalized(
|
|
145
|
+
cx: number,
|
|
146
|
+
cy: number,
|
|
147
|
+
canvasW: number,
|
|
148
|
+
canvasH: number,
|
|
149
|
+
imgW: number,
|
|
150
|
+
imgH: number,
|
|
151
|
+
): { x: number; y: number } | null {
|
|
152
|
+
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
|
|
153
|
+
if (
|
|
154
|
+
cx < rect.x ||
|
|
155
|
+
cx > rect.x + rect.w ||
|
|
156
|
+
cy < rect.y ||
|
|
157
|
+
cy > rect.y + rect.h
|
|
158
|
+
) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
x: (cx - rect.x) / rect.w,
|
|
163
|
+
y: (cy - rect.y) / rect.h,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Inverse of the Skia Group transform applied during pinch-zoom.
|
|
169
|
+
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
|
170
|
+
* space where the image and regions are positioned before any scale/pan.
|
|
171
|
+
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
|
172
|
+
*/
|
|
173
|
+
function screenToCanvasCoords(
|
|
174
|
+
screenX: number,
|
|
175
|
+
screenY: number,
|
|
176
|
+
canvasW: number,
|
|
177
|
+
canvasH: number,
|
|
178
|
+
zoomScale: number,
|
|
179
|
+
panOffset: { x: number; y: number },
|
|
180
|
+
): { x: number; y: number } {
|
|
181
|
+
if (zoomScale <= 1) return { x: screenX, y: screenY };
|
|
182
|
+
// Reverse: translate(-pan) → unscale around center → translate(+center)
|
|
183
|
+
return {
|
|
184
|
+
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
|
|
185
|
+
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pointInPolygon(
|
|
190
|
+
x: number,
|
|
191
|
+
y: number,
|
|
192
|
+
points: { x: number; y: number }[],
|
|
193
|
+
): boolean {
|
|
194
|
+
let inside = false;
|
|
195
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
196
|
+
const xi = points[i].x;
|
|
197
|
+
const yi = points[i].y;
|
|
198
|
+
const xj = points[j].x;
|
|
199
|
+
const yj = points[j].y;
|
|
200
|
+
const intersect =
|
|
201
|
+
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
202
|
+
if (intersect) inside = !inside;
|
|
203
|
+
}
|
|
204
|
+
return inside;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function pointInPolygonWithPadding(
|
|
208
|
+
x: number,
|
|
209
|
+
y: number,
|
|
210
|
+
points: { x: number; y: number }[],
|
|
211
|
+
padding: number,
|
|
212
|
+
): boolean {
|
|
213
|
+
if (points.length < 3) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let minX = points[0].x;
|
|
218
|
+
let maxX = points[0].x;
|
|
219
|
+
let minY = points[0].y;
|
|
220
|
+
let maxY = points[0].y;
|
|
221
|
+
for (const point of points) {
|
|
222
|
+
minX = Math.min(minX, point.x);
|
|
223
|
+
maxX = Math.max(maxX, point.x);
|
|
224
|
+
minY = Math.min(minY, point.y);
|
|
225
|
+
maxY = Math.max(maxY, point.y);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
x >= minX - padding &&
|
|
230
|
+
x <= maxX + padding &&
|
|
231
|
+
y >= minY - padding &&
|
|
232
|
+
y <= maxY + padding
|
|
233
|
+
) {
|
|
234
|
+
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return pointInPolygon(x, y, points);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getRegionHitPolygons(reg: SegmentRegion): { x: number; y: number }[][] {
|
|
243
|
+
return reg.hitPolygons && reg.hitPolygons.length > 0
|
|
244
|
+
? reg.hitPolygons
|
|
245
|
+
: reg.polygons;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function pointHitsRegion(
|
|
249
|
+
x: number,
|
|
250
|
+
y: number,
|
|
251
|
+
reg: SegmentRegion,
|
|
252
|
+
options?: { thinPadding?: number },
|
|
253
|
+
): boolean {
|
|
254
|
+
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
|
255
|
+
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
|
|
256
|
+
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
|
|
257
|
+
return getRegionHitPolygons(reg).some(
|
|
258
|
+
poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean {
|
|
263
|
+
return getRegionHitPolygons(reg).some(
|
|
264
|
+
poly => poly.length >= 3 && pointInPolygon(x, y, poly),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolveRegionHit(
|
|
269
|
+
regions: SegmentRegion[],
|
|
270
|
+
x: number,
|
|
271
|
+
y: number,
|
|
272
|
+
): number | null {
|
|
273
|
+
const hits: SegmentRegion[] = [];
|
|
274
|
+
|
|
275
|
+
for (const reg of regions) {
|
|
276
|
+
const bboxPad = reg.thinStrip ? 0.005 : 0;
|
|
277
|
+
const b = reg.bbox;
|
|
278
|
+
if (
|
|
279
|
+
x < b.x - bboxPad ||
|
|
280
|
+
x > b.x + b.w + bboxPad ||
|
|
281
|
+
y < b.y - bboxPad ||
|
|
282
|
+
y > b.y + b.h + bboxPad
|
|
283
|
+
) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (pointHitsRegion(x, y, reg)) {
|
|
287
|
+
hits.push(reg);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (hits.length === 0) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (hits.length === 1) {
|
|
295
|
+
return hits[0].id;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const strictNonThin = hits.filter(
|
|
299
|
+
reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
|
300
|
+
);
|
|
301
|
+
if (strictNonThin.length > 0) {
|
|
302
|
+
strictNonThin.sort((a, b) => a.area - b.area);
|
|
303
|
+
return strictNonThin[0].id;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const strictThin = hits.filter(
|
|
307
|
+
reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
|
308
|
+
);
|
|
309
|
+
if (strictThin.length > 0) {
|
|
310
|
+
strictThin.sort((a, b) => a.area - b.area);
|
|
311
|
+
return strictThin[0].id;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const nonThin = hits.filter(reg => !reg.thinStrip);
|
|
315
|
+
if (nonThin.length > 0) {
|
|
316
|
+
nonThin.sort((a, b) => a.area - b.area);
|
|
317
|
+
return nonThin[0].id;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
hits.sort((a, b) => a.area - b.area);
|
|
321
|
+
return hits[0].id;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pickKickRegionFromMask(
|
|
325
|
+
normX: number,
|
|
326
|
+
normY: number,
|
|
327
|
+
pick: { buffer: Uint8Array; cols: number; rows: number },
|
|
328
|
+
kickRegionId: number,
|
|
329
|
+
baseboardPickMask?: Uint8Array | null,
|
|
330
|
+
strict = false,
|
|
331
|
+
): number | null {
|
|
332
|
+
const cx = Math.floor(normX * pick.cols);
|
|
333
|
+
const cy = Math.floor(normY * pick.rows);
|
|
334
|
+
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (strict) {
|
|
339
|
+
if (baseboardPickMask) {
|
|
340
|
+
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
|
|
341
|
+
}
|
|
342
|
+
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
|
|
343
|
+
? kickRegionId
|
|
344
|
+
: null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
|
348
|
+
const radius = Math.max(
|
|
349
|
+
interaction.kickMaskPickRadiusPx,
|
|
350
|
+
Math.floor(pick.cols * 0.022),
|
|
351
|
+
);
|
|
352
|
+
const radiusSq = radius * radius;
|
|
353
|
+
|
|
354
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
355
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
356
|
+
if (dx * dx + dy * dy > radiusSq) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const x = cx + dx;
|
|
360
|
+
const y = cy + dy;
|
|
361
|
+
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (baseboardPickMask) {
|
|
365
|
+
if (baseboardPickMask[y * pick.cols + x]) {
|
|
366
|
+
return kickRegionId;
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
isBaseboardMaskPixel(
|
|
372
|
+
pick.buffer,
|
|
373
|
+
pick.cols,
|
|
374
|
+
pick.rows,
|
|
375
|
+
x,
|
|
376
|
+
y,
|
|
377
|
+
)
|
|
378
|
+
) {
|
|
379
|
+
return kickRegionId;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function pickKickNearStrip(
|
|
388
|
+
normX: number,
|
|
389
|
+
normY: number,
|
|
390
|
+
kickReg: SegmentRegion,
|
|
391
|
+
): boolean {
|
|
392
|
+
const polys = kickReg.hitPolygons ?? kickReg.polygons;
|
|
393
|
+
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
|
|
394
|
+
return polys.some(
|
|
395
|
+
poly =>
|
|
396
|
+
poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function lookupRegionFromPickMap(
|
|
401
|
+
normX: number,
|
|
402
|
+
normY: number,
|
|
403
|
+
pick: { buffer: Uint8Array; cols: number; rows: number },
|
|
404
|
+
radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
|
|
405
|
+
): number | null {
|
|
406
|
+
const cx = Math.min(
|
|
407
|
+
pick.cols - 1,
|
|
408
|
+
Math.max(0, Math.floor(normX * pick.cols)),
|
|
409
|
+
);
|
|
410
|
+
const cy = Math.min(
|
|
411
|
+
pick.rows - 1,
|
|
412
|
+
Math.max(0, Math.floor(normY * pick.rows)),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const readCode = (x: number, y: number) => pick.buffer[y * pick.cols + x];
|
|
416
|
+
|
|
417
|
+
const center = readCode(cx, cy);
|
|
418
|
+
if (center > 0) {
|
|
419
|
+
return center - 1;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (radiusPx <= 0) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const r = Math.max(4, radiusPx);
|
|
427
|
+
const rSq = r * r;
|
|
428
|
+
for (let dy = -r; dy <= r; dy++) {
|
|
429
|
+
for (let dx = -r; dx <= r; dx++) {
|
|
430
|
+
if (dx * dx + dy * dy > rSq) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const x = cx + dx;
|
|
434
|
+
const y = cy + dy;
|
|
435
|
+
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const code = readCode(x, y);
|
|
439
|
+
if (code > 0) {
|
|
440
|
+
return code - 1;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** BGR → 屏幕 RGB */
|
|
449
|
+
function bgrToCss(b: number, g: number, r: number): string {
|
|
450
|
+
return `rgb(${r},${g},${b})`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
|
|
454
|
+
if (!layers) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
layers.lowFreqImage.dispose();
|
|
458
|
+
layers.highFreqImage.dispose();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function releaseOriginSkImage(image: SkImage | null) {
|
|
462
|
+
if (image) {
|
|
463
|
+
image.dispose();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type WorkScaledBgr = {
|
|
468
|
+
buffer: Uint8Array;
|
|
469
|
+
cols: number;
|
|
470
|
+
rows: number;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
async function prepareWorkScaledBgrBuffer(
|
|
474
|
+
bgrBuffer: Uint8Array,
|
|
475
|
+
cols: number,
|
|
476
|
+
rows: number,
|
|
477
|
+
workScale: number,
|
|
478
|
+
): Promise<WorkScaledBgr> {
|
|
479
|
+
if (workScale >= 1) {
|
|
480
|
+
return { buffer: bgrBuffer, cols, rows };
|
|
481
|
+
}
|
|
482
|
+
const workCols = Math.floor(cols * workScale);
|
|
483
|
+
const workRows = Math.floor(rows * workScale);
|
|
484
|
+
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
|
|
485
|
+
return { buffer, cols: workCols, rows: workRows };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ==========================================================================
|
|
489
|
+
* 分段计时工具(仅开发环境生效)
|
|
490
|
+
* ========================================================================== */
|
|
491
|
+
let _timeLogTs = 0;
|
|
492
|
+
function timeLog(tag: string) {
|
|
493
|
+
if (!__DEV__) return;
|
|
494
|
+
const now = performance.now();
|
|
495
|
+
const dt = _timeLogTs ? now - _timeLogTs : 0;
|
|
496
|
+
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
|
|
497
|
+
_timeLogTs = now;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* ==========================================================================
|
|
501
|
+
* 组件主体
|
|
502
|
+
* ========================================================================== */
|
|
503
|
+
const MaskSegmentCanvas = forwardRef<MaskSegmentCanvasRef, MaskSegmentCanvasProps>(
|
|
504
|
+
function MaskSegmentCanvas(props, ref) {
|
|
505
|
+
const {
|
|
506
|
+
originUrl: originUrlProp,
|
|
507
|
+
maskUrl: maskUrlProp,
|
|
508
|
+
originImgPath: originImgPathLegacy,
|
|
509
|
+
maskImgPath: maskImgPathLegacy,
|
|
510
|
+
maskConfig,
|
|
511
|
+
pipelinePreset,
|
|
512
|
+
pipelineConfig,
|
|
513
|
+
paintConfig,
|
|
514
|
+
interactionConfig,
|
|
515
|
+
semanticColors,
|
|
516
|
+
regionOutlineColor,
|
|
517
|
+
initialSession,
|
|
518
|
+
initialPaintColor,
|
|
519
|
+
initialPaintConfigJson,
|
|
520
|
+
showDebugPickers = true,
|
|
521
|
+
showToolbar = true,
|
|
522
|
+
showColorBar = true,
|
|
523
|
+
showStatusRow = true,
|
|
524
|
+
showOverlayButtons = true,
|
|
525
|
+
disabled = false,
|
|
526
|
+
style,
|
|
527
|
+
canvasStyle,
|
|
528
|
+
maxHeight,
|
|
529
|
+
undoButtonStyle,
|
|
530
|
+
compareButtonStyle,
|
|
531
|
+
undoButtonTextStyle,
|
|
532
|
+
compareButtonTextStyle,
|
|
533
|
+
undoButtonText = '撤销',
|
|
534
|
+
compareButtonText = '对比原图',
|
|
535
|
+
compareExitButtonText = '退出对比',
|
|
536
|
+
renderUndoButton,
|
|
537
|
+
renderCompareButton,
|
|
538
|
+
onWatch,
|
|
539
|
+
onPaintCallback,
|
|
540
|
+
onError,
|
|
541
|
+
autoExportOnReady,
|
|
542
|
+
onExported,
|
|
543
|
+
} = props;
|
|
544
|
+
|
|
545
|
+
const originSource = originUrlProp ?? originImgPathLegacy ?? '';
|
|
546
|
+
const maskSource = maskUrlProp ?? maskImgPathLegacy ?? '';
|
|
547
|
+
|
|
548
|
+
const resolvedMaskConfig = useMemo(
|
|
549
|
+
() =>
|
|
550
|
+
semanticColors
|
|
551
|
+
? { ...maskConfig, semanticColors }
|
|
552
|
+
: maskConfig,
|
|
553
|
+
[maskConfig, semanticColors],
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const resolvedPaintConfig = useMemo(
|
|
557
|
+
() =>
|
|
558
|
+
regionOutlineColor
|
|
559
|
+
? { ...paintConfig, regionOverlayFill: regionOutlineColor }
|
|
560
|
+
: paintConfig,
|
|
561
|
+
[paintConfig, regionOutlineColor],
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const [resolvedOriginPath, setResolvedOriginPath] = useState('');
|
|
565
|
+
const [resolvedMaskPath, setResolvedMaskPath] = useState('');
|
|
566
|
+
const [originImgPath, setOriginImgPath] = useState(resolvedOriginPath);
|
|
567
|
+
const [maskImgPath, setMaskImgPath] = useState(resolvedMaskPath);
|
|
568
|
+
|
|
569
|
+
// Latest desired image paths (updated when the internal path states settle).
|
|
570
|
+
// Used by segmentAndPrepareLayers to decide whether a stale runId (from effect cleanup due to
|
|
571
|
+
// unrelated parent re-renders) should actually abort the current async read/segment work.
|
|
572
|
+
// If the image pair we are processing is still the one the caller ultimately wants, we continue.
|
|
573
|
+
const latestOriginPathRef = useRef<string>('');
|
|
574
|
+
const latestMaskPathRef = useRef<string>('');
|
|
575
|
+
|
|
576
|
+
const resolvedPipelineConfig = useMemo(
|
|
577
|
+
() => resolvePipelineConfig(pipelinePreset, pipelineConfig),
|
|
578
|
+
[pipelinePreset, pipelineConfig],
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const runtimeRef = useRef(createRuntimeConfig({
|
|
582
|
+
maskConfig: resolvedMaskConfig,
|
|
583
|
+
pipelineConfig: resolvedPipelineConfig,
|
|
584
|
+
paintConfig: resolvedPaintConfig,
|
|
585
|
+
interactionConfig,
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
// Track last *values* we pushed for paintConfig. We only call the global setMaskSegmentRuntimeConfig
|
|
589
|
+
// (which bumps runtimeRevision) when the actual numbers change. This prevents repeated parent
|
|
590
|
+
// re-renders that pass a new object literal with identical values from causing:
|
|
591
|
+
// - global revision bump
|
|
592
|
+
// - paintColorMap useMemo invalidation (full-res Uint8Array + boxBlur + Skia image alloc)
|
|
593
|
+
// - extra main-thread work during the critical segmentation / freq-layers / outline paths window.
|
|
594
|
+
// The local runtimeRef is still kept in sync for any synchronous readers.
|
|
595
|
+
const lastAppliedPaintConfigRef = useRef<Record<string, unknown> | null>(null);
|
|
596
|
+
const lastAppliedPipelineSignatureRef = useRef<string | null>(null);
|
|
597
|
+
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
const prevPaint = lastAppliedPaintConfigRef.current;
|
|
600
|
+
const currPaint = resolvedPaintConfig || {};
|
|
601
|
+
const paintKeys = [
|
|
602
|
+
'colorBaseOpacity',
|
|
603
|
+
'lLightOpacity',
|
|
604
|
+
'textureOpacity',
|
|
605
|
+
'lLowBlurKernel',
|
|
606
|
+
'lLowContrast',
|
|
607
|
+
'lLowBrightness',
|
|
608
|
+
'lHighGain',
|
|
609
|
+
'maskFeatherColor',
|
|
610
|
+
'maskFeatherTexture',
|
|
611
|
+
'regionOverlayFill',
|
|
612
|
+
] as const;
|
|
613
|
+
const paintChanged =
|
|
614
|
+
!prevPaint || paintKeys.some((k) => (currPaint as any)[k] !== (prevPaint as any)[k]);
|
|
615
|
+
|
|
616
|
+
const pipelineSignature = JSON.stringify(resolvedPipelineConfig);
|
|
617
|
+
const pipelineChanged =
|
|
618
|
+
lastAppliedPipelineSignatureRef.current !== pipelineSignature;
|
|
619
|
+
|
|
620
|
+
if (paintChanged || pipelineChanged) {
|
|
621
|
+
if (paintChanged) {
|
|
622
|
+
lastAppliedPaintConfigRef.current = { ...currPaint };
|
|
623
|
+
}
|
|
624
|
+
if (pipelineChanged) {
|
|
625
|
+
lastAppliedPipelineSignatureRef.current = pipelineSignature;
|
|
626
|
+
}
|
|
627
|
+
runtimeRef.current = setMaskSegmentRuntimeConfig({
|
|
628
|
+
maskConfig: resolvedMaskConfig,
|
|
629
|
+
pipelineConfig: resolvedPipelineConfig,
|
|
630
|
+
paintConfig: resolvedPaintConfig,
|
|
631
|
+
interactionConfig,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}, [
|
|
635
|
+
resolvedMaskConfig,
|
|
636
|
+
resolvedPipelineConfig,
|
|
637
|
+
resolvedPaintConfig,
|
|
638
|
+
interactionConfig,
|
|
639
|
+
]);
|
|
640
|
+
|
|
641
|
+
const paintPalette = runtimeRef.current.paint.palette;
|
|
642
|
+
const paintRuntime = getMaskSegmentRuntimeConfig().paint;
|
|
643
|
+
const interactionRuntime = getMaskSegmentRuntimeConfig().interaction;
|
|
644
|
+
|
|
645
|
+
const onWatchRef = useRef(onWatch);
|
|
646
|
+
const onPaintCallbackRef = useRef(onPaintCallback);
|
|
647
|
+
const onErrorRef = useRef(onError);
|
|
648
|
+
const onExportedRef = useRef(onExported);
|
|
649
|
+
useEffect(() => {
|
|
650
|
+
onWatchRef.current = onWatch;
|
|
651
|
+
onPaintCallbackRef.current = onPaintCallback;
|
|
652
|
+
onErrorRef.current = onError;
|
|
653
|
+
onExportedRef.current = onExported;
|
|
654
|
+
}, [onWatch, onPaintCallback, onError, onExported]);
|
|
655
|
+
|
|
656
|
+
const watchStartRef = useRef(0);
|
|
657
|
+
const lastWatchStateRef = useRef<MaskSegmentWatchState | null>(null);
|
|
658
|
+
const lastWatchSignatureRef = useRef<string | null>(null);
|
|
659
|
+
|
|
660
|
+
const emitWatch = useCallback(
|
|
661
|
+
(state: MaskSegmentWatchState, detail?: MaskSegmentWatchDetail) => {
|
|
662
|
+
const signature = [
|
|
663
|
+
state,
|
|
664
|
+
detail?.regionCount ?? '',
|
|
665
|
+
detail?.maskPathsReady ?? '',
|
|
666
|
+
detail?.freqLayersReady ?? '',
|
|
667
|
+
detail?.errorMessage ?? '',
|
|
668
|
+
].join('|');
|
|
669
|
+
if (lastWatchSignatureRef.current === signature) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
lastWatchSignatureRef.current = signature;
|
|
673
|
+
lastWatchStateRef.current = state;
|
|
674
|
+
const durationMs = watchStartRef.current
|
|
675
|
+
? performance.now() - watchStartRef.current
|
|
676
|
+
: 0;
|
|
677
|
+
onWatchRef.current?.(state, durationMs, detail);
|
|
678
|
+
},
|
|
679
|
+
[],
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const reportError = useCallback((message: string, error?: unknown) => {
|
|
683
|
+
emitWatch('error', { errorMessage: message });
|
|
684
|
+
if (onErrorRef.current) {
|
|
685
|
+
onErrorRef.current(message, error);
|
|
686
|
+
} else if (__DEV__) {
|
|
687
|
+
console.error('[MaskSegment]', message, error);
|
|
688
|
+
}
|
|
689
|
+
}, [emitWatch]);
|
|
690
|
+
|
|
691
|
+
const [customPaintColor, setCustomPaintColor] = useState<BgrColor | null>(
|
|
692
|
+
initialPaintColor ?? null,
|
|
693
|
+
);
|
|
694
|
+
const customPaintConfigJsonRef = useRef<Record<string, unknown> | undefined>(
|
|
695
|
+
initialPaintConfigJson,
|
|
696
|
+
);
|
|
697
|
+
const originUrlRef = useRef(originSource);
|
|
698
|
+
const maskUrlRef = useRef(maskSource);
|
|
699
|
+
|
|
700
|
+
useEffect(() => {
|
|
701
|
+
originUrlRef.current = originSource;
|
|
702
|
+
maskUrlRef.current = maskSource;
|
|
703
|
+
}, [originSource, maskSource]);
|
|
704
|
+
|
|
705
|
+
useEffect(() => {
|
|
706
|
+
let cancelled = false;
|
|
707
|
+
if (!originSource || !maskSource) {
|
|
708
|
+
setResolvedOriginPath('');
|
|
709
|
+
setResolvedMaskPath('');
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
void (async () => {
|
|
714
|
+
try {
|
|
715
|
+
const [originPath, maskPath] = await Promise.all([
|
|
716
|
+
resolveImageUrl(originSource, 'origin.png'),
|
|
717
|
+
resolveImageUrl(maskSource, 'mask.png'),
|
|
718
|
+
]);
|
|
719
|
+
if (!cancelled) {
|
|
720
|
+
setResolvedOriginPath(originPath);
|
|
721
|
+
setResolvedMaskPath(maskPath);
|
|
722
|
+
}
|
|
723
|
+
} catch (e) {
|
|
724
|
+
if (!cancelled) {
|
|
725
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
726
|
+
reportError(msg, e);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
})();
|
|
730
|
+
|
|
731
|
+
return () => {
|
|
732
|
+
cancelled = true;
|
|
733
|
+
};
|
|
734
|
+
}, [originSource, maskSource, reportError]);
|
|
735
|
+
|
|
736
|
+
useEffect(() => {
|
|
737
|
+
setOriginImgPath(resolvedOriginPath);
|
|
738
|
+
setMaskImgPath(resolvedMaskPath);
|
|
739
|
+
if (resolvedOriginPath && resolvedMaskPath) {
|
|
740
|
+
prewarmPngBgrCache([resolvedOriginPath, resolvedMaskPath]);
|
|
741
|
+
}
|
|
742
|
+
}, [resolvedOriginPath, resolvedMaskPath]);
|
|
743
|
+
|
|
744
|
+
// Keep latest desired paths for cancellation decisions inside the long async segment pipeline.
|
|
745
|
+
useEffect(() => {
|
|
746
|
+
latestOriginPathRef.current = originImgPath || '';
|
|
747
|
+
latestMaskPathRef.current = maskImgPath || '';
|
|
748
|
+
}, [originImgPath, maskImgPath]);
|
|
749
|
+
|
|
750
|
+
const [paintResourceLayers, setPaintResourceLayers] =
|
|
751
|
+
useState<PaintResourceLayers | null>(null);
|
|
752
|
+
const paintResourceLayersRef = useRef<PaintResourceLayers | null>(null);
|
|
753
|
+
const paintColorMapSkImgRef = useRef<SkImage | null>(null);
|
|
754
|
+
|
|
755
|
+
const [activeBrushIndex, setActiveBrushIndex] = useState<number | null>(null);
|
|
756
|
+
const [paintedRegions, setPaintedRegions] = useState<Map<number, BgrColor>>(
|
|
757
|
+
() => new Map(),
|
|
758
|
+
);
|
|
759
|
+
const paintedRegionsRef = useRef<Map<number, BgrColor>>(new Map());
|
|
760
|
+
const [paintHistory, setPaintHistory] = useState<number[]>([]);
|
|
761
|
+
|
|
762
|
+
// Seed the ref with the initial empty map so early reads (before any paint effect)
|
|
763
|
+
// are consistent.
|
|
764
|
+
paintedRegionsRef.current = paintedRegions;
|
|
765
|
+
const [heldRegionId, setHeldRegionId] = useState<number | null>(null);
|
|
766
|
+
const [heldRegionAnchor, setHeldRegionAnchor] = useState<{
|
|
767
|
+
x: number;
|
|
768
|
+
y: number;
|
|
769
|
+
} | null>(null);
|
|
770
|
+
const [initFlashRegionId, setInitFlashRegionId] = useState<number | null>(null);
|
|
771
|
+
const initFlashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
772
|
+
const initFlashIndexRef = useRef(0);
|
|
773
|
+
const initFlashActiveRef = useRef(false);
|
|
774
|
+
// List of regions still eligible for the init dashed-outline flash (discovery aid).
|
|
775
|
+
// Computed at the start of the flash loop, excluding any that are already painted.
|
|
776
|
+
// This ensures that on continue-edit (or any partial seed), already-colored regions
|
|
777
|
+
// do not get the flashing dashed outline.
|
|
778
|
+
const initFlashListRef = useRef<SegmentRegion[]>([]);
|
|
779
|
+
// Guard so that initialSession (seed from host bootstrap or scheme) is applied only once
|
|
780
|
+
// after segmentsReady. Prevents later prop identity changes (e.g. caused by host slot/brush
|
|
781
|
+
// selection re-renders) from calling restoreSession again, which would clobber live
|
|
782
|
+
// paintedRegions with a re-derived snapshot (often causing already-painted regions to
|
|
783
|
+
// "follow" the newly selected brush color).
|
|
784
|
+
const hasAppliedInitialSessionRef = useRef(false);
|
|
785
|
+
|
|
786
|
+
// Keep a ref to the absolute latest paintedRegions so that imperative save()
|
|
787
|
+
// (called from host performSaveProject for new schemes, or manually) and
|
|
788
|
+
// internal composites always see the most up-to-date painted state, even
|
|
789
|
+
// if the useImperativeHandle closure was created in a prior render.
|
|
790
|
+
// This fixes cases where the last user paint's colors were missing from
|
|
791
|
+
// the recolored After image captured at "save scheme" time.
|
|
792
|
+
useEffect(() => {
|
|
793
|
+
paintedRegionsRef.current = paintedRegions;
|
|
794
|
+
}, [paintedRegions]);
|
|
795
|
+
|
|
796
|
+
// Cached export from the most recent auto-export or save() — keyed by painted fingerprint.
|
|
797
|
+
const lastExportCacheRef = useRef<{ fingerprint: string; result: SavePaintResult } | null>(null);
|
|
798
|
+
const autoExportDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
799
|
+
const exportInFlightRef = useRef(false);
|
|
800
|
+
|
|
801
|
+
const regionsRef = useRef<SegmentRegion[]>([]);
|
|
802
|
+
const maskPickRef = useRef<{
|
|
803
|
+
buffer: Uint8Array;
|
|
804
|
+
cols: number;
|
|
805
|
+
rows: number;
|
|
806
|
+
} | null>(null);
|
|
807
|
+
const regionPickRef = useRef<{
|
|
808
|
+
buffer: Uint8Array;
|
|
809
|
+
cols: number;
|
|
810
|
+
rows: number;
|
|
811
|
+
} | null>(null);
|
|
812
|
+
const regionMaskDataRef = useRef<{
|
|
813
|
+
labels: Uint8Array;
|
|
814
|
+
baseboardBinary: Uint8Array;
|
|
815
|
+
cols: number;
|
|
816
|
+
rows: number;
|
|
817
|
+
} | null>(null);
|
|
818
|
+
const workBufferRef = useRef<WorkScaledBgr | null>(null);
|
|
819
|
+
const paintLayersPromiseRef = useRef<Promise<void> | null>(null);
|
|
820
|
+
const loadPaintLayersRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
|
821
|
+
const [paintResourcesReady, setPaintResourcesReady] = useState(false);
|
|
822
|
+
const [layersLoading, setLayersLoading] = useState(false);
|
|
823
|
+
const [maskPathsReady, setMaskPathsReady] = useState(false);
|
|
824
|
+
const baseboardPickMaskRef = useRef<Uint8Array | null>(null);
|
|
825
|
+
const kickRegionIdRef = useRef<number | null>(null);
|
|
826
|
+
const [regionPalette, setRegionPalette] = useState<SegmentRegion[]>([]);
|
|
827
|
+
const [regionCount, setRegionCount] = useState(0);
|
|
828
|
+
|
|
829
|
+
const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>(
|
|
830
|
+
null,
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
// High-resolution offscreen Canvas (sized to the work buffer resolution) whose content
|
|
834
|
+
// is the full shader composition (PaintShaderLayer at 0,0,workW,workH). On save() we
|
|
835
|
+
// call makeImageSnapshot() on it to get a "what you see in the editor, at source res"
|
|
836
|
+
// PNG bytes. This is the preferred "保存快照" path and avoids CPU recolor entirely
|
|
837
|
+
// for the exported After.
|
|
838
|
+
const highResExportCanvasRef = useCanvasRef();
|
|
839
|
+
const [exportCanvasSize, setExportCanvasSize] = useState<{ w: number; h: number } | null>(null);
|
|
840
|
+
// Gate the (potentially expensive) high-res snapshot canvas so it is only mounted
|
|
841
|
+
// after the user (or initialSession seed) has painted at least one region. This keeps
|
|
842
|
+
// idle segmentation / no-paint cases cheap.
|
|
843
|
+
const [highResSnapshotEnabled, setHighResSnapshotEnabled] = useState(false);
|
|
844
|
+
|
|
845
|
+
// Layout measurement for the root container of this component. Declared early so
|
|
846
|
+
// the canvasW/canvasH memos (which decide the viewport rect for zoom centering,
|
|
847
|
+
// containRect placement, clipping, and gesture coordinate mapping) can close over it.
|
|
848
|
+
// When the host passes a fitted frame (VisualizationScreen's canvasFrame with explicit
|
|
849
|
+
// w/h derived from safe area + aspect, or scheme cards), we size our internal Skia
|
|
850
|
+
// canvas + gesture layer + zoom transform to that exact allocated rect.
|
|
851
|
+
const [layoutWidth, setLayoutWidth] = useState<number | null>(null);
|
|
852
|
+
const [layoutHeight, setLayoutHeight] = useState<number | null>(null);
|
|
853
|
+
|
|
854
|
+
const [segmentsReady, setSegmentsReady] = useState(false);
|
|
855
|
+
const segmentsReadyRef = useRef(false);
|
|
856
|
+
const maskPathsReadyRef = useRef(false);
|
|
857
|
+
const [canvasInteractive, setCanvasInteractive] = useState(false);
|
|
858
|
+
const [segError, setSegError] = useState('');
|
|
859
|
+
const [compareMode, setCompareMode] = useState(false);
|
|
860
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
861
|
+
|
|
862
|
+
const [originSkImg, setOriginSkImg] = useState<SkImage | null>(null);
|
|
863
|
+
const originSkImgRef = useRef<SkImage | null>(null);
|
|
864
|
+
const lowFreqSkImg = paintResourceLayers?.lowFreqImage ?? null;
|
|
865
|
+
const highFreqSkImg = paintResourceLayers?.highFreqImage ?? null;
|
|
866
|
+
|
|
867
|
+
const canvasBaseW = SCREEN_WIDTH - 20;
|
|
868
|
+
|
|
869
|
+
// The "viewport" size for this canvas component: the rect inside which we place
|
|
870
|
+
// the contained image, apply the zoom Group transform (centered), clip, and receive
|
|
871
|
+
// gestures (tap + two-finger pinch). When we have a real onLayout from the host
|
|
872
|
+
// (VisualizationScreen's aspect-fitted canvasFrame, or scheme card preview area),
|
|
873
|
+
// we use the *allocated pixel size* directly as our viewport.
|
|
874
|
+
//
|
|
875
|
+
// Accurate canvasW/H is still critical for:
|
|
876
|
+
// - Correct centering of the scaled content around the viewport center.
|
|
877
|
+
// - Proper containRect (letterbox/centering of the source photo inside the viewport).
|
|
878
|
+
// - Clip rect and gesture-to-canvas coordinate conversion used by painting.
|
|
879
|
+
//
|
|
880
|
+
// Previously the code fell back to aspect-derived sizes even after layout, which
|
|
881
|
+
// could cause the effective viewport to not match the host frame. Using the onLayout
|
|
882
|
+
// result (with maxHeight fallback only when no layout yet) keeps zoom, clip, and
|
|
883
|
+
// touch mapping consistent with what the user actually sees and touches.
|
|
884
|
+
const viewportW = useMemo(() => {
|
|
885
|
+
if (layoutWidth != null && layoutHeight != null) {
|
|
886
|
+
// Primary path for viz screen and scheme cards: the exact size the host
|
|
887
|
+
// decided for this component (after its own safe-area + aspect fit).
|
|
888
|
+
return layoutWidth;
|
|
889
|
+
}
|
|
890
|
+
if (!maxHeight || maxHeight <= 0) {
|
|
891
|
+
return canvasBaseW;
|
|
892
|
+
}
|
|
893
|
+
// Fallback (no layout yet, or other usages that pass maxHeight without a
|
|
894
|
+
// tightly sized parent frame). Replicate a contain-style budget.
|
|
895
|
+
const availableW = canvasBaseW;
|
|
896
|
+
let auxHeight = 0;
|
|
897
|
+
if (showToolbar) auxHeight += 40;
|
|
898
|
+
if (showStatusRow) auxHeight += 30;
|
|
899
|
+
if (showColorBar) auxHeight += 70;
|
|
900
|
+
const availableH = Math.max(100, maxHeight - 20 - auxHeight);
|
|
901
|
+
const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
|
|
902
|
+
const containerAspect = availableW / availableH;
|
|
903
|
+
if (containerAspect > imgAspect) {
|
|
904
|
+
return Math.floor(availableH * imgAspect);
|
|
905
|
+
}
|
|
906
|
+
return availableW;
|
|
907
|
+
}, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, canvasBaseW, imageSize]);
|
|
908
|
+
|
|
909
|
+
const viewportH = useMemo(() => {
|
|
910
|
+
if (layoutWidth != null && layoutHeight != null) {
|
|
911
|
+
return layoutHeight;
|
|
912
|
+
}
|
|
913
|
+
if (!maxHeight || maxHeight <= 0) {
|
|
914
|
+
const imgAspect = imageSize ? imageSize.w / imageSize.h : 1;
|
|
915
|
+
return Math.floor(viewportW / imgAspect);
|
|
916
|
+
}
|
|
917
|
+
let auxHeight = 0;
|
|
918
|
+
if (showToolbar) auxHeight += 40;
|
|
919
|
+
if (showStatusRow) auxHeight += 30;
|
|
920
|
+
if (showColorBar) auxHeight += 70;
|
|
921
|
+
return Math.max(100, maxHeight - 20 - auxHeight);
|
|
922
|
+
}, [layoutWidth, layoutHeight, maxHeight, showToolbar, showStatusRow, showColorBar, viewportW, imageSize]);
|
|
923
|
+
|
|
924
|
+
// For the rest of the component, "canvasW/H" means the viewport rect size.
|
|
925
|
+
// All zoom center, wrap size, touch layer, Canvas size, clip, containRect, and
|
|
926
|
+
// gesture coordinate mapping are based on this, so that two-finger zoom centering
|
|
927
|
+
// and single-finger tap painting stay consistent with the host-allocated area.
|
|
928
|
+
const canvasW = viewportW;
|
|
929
|
+
const canvasH = viewportH;
|
|
930
|
+
|
|
931
|
+
// Refs synced to the latest viewport size so that async callbacks
|
|
932
|
+
// (segmentAndPrepareLayers) always read post-layout values instead of
|
|
933
|
+
// stale closure captures. This fixes dashed-outline offset to the bottom
|
|
934
|
+
// when the initial pathMapRect was computed with fallback SCREEN_WIDTH.
|
|
935
|
+
// Declared before segmentAndPrepareLayers so the async body can reference them.
|
|
936
|
+
const canvasWRef = useRef(canvasW);
|
|
937
|
+
const canvasHRef = useRef(canvasH);
|
|
938
|
+
|
|
939
|
+
// ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ─────────
|
|
940
|
+
const [zoomScale, setZoomScale] = useState(1);
|
|
941
|
+
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
|
942
|
+
|
|
943
|
+
// Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
|
|
944
|
+
const zoomScaleRef = useRef(1);
|
|
945
|
+
const panOffsetRef = useRef({ x: 0, y: 0 });
|
|
946
|
+
// Baseline value captured at gesture start to avoid jump on re-creation for pinch
|
|
947
|
+
const zoomBaseRef = useRef(1);
|
|
948
|
+
// Ref to the latest containRect (the actual placed photo rect inside the viewport).
|
|
949
|
+
const containRectRef = useRef<ContainRect | null>(null);
|
|
950
|
+
|
|
951
|
+
useEffect(() => { zoomScaleRef.current = zoomScale; }, [zoomScale]);
|
|
952
|
+
useEffect(() => { panOffsetRef.current = panOffset; }, [panOffset]);
|
|
953
|
+
|
|
954
|
+
const resetZoom = useCallback(() => {
|
|
955
|
+
setZoomScale(1);
|
|
956
|
+
setPanOffset({ x: 0, y: 0 });
|
|
957
|
+
zoomScaleRef.current = 1;
|
|
958
|
+
panOffsetRef.current = { x: 0, y: 0 };
|
|
959
|
+
}, []);
|
|
960
|
+
|
|
961
|
+
const containRect = useMemo(() => {
|
|
962
|
+
if (!imageSize) return null;
|
|
963
|
+
return getContainRect(canvasW, canvasH, imageSize.w, imageSize.h);
|
|
964
|
+
}, [imageSize, canvasW, canvasH]);
|
|
965
|
+
|
|
966
|
+
// Keep a ref in sync so early-defined callbacks can read the
|
|
967
|
+
// latest containRect without TDZ or stale closure issues.
|
|
968
|
+
useEffect(() => {
|
|
969
|
+
containRectRef.current = containRect;
|
|
970
|
+
}, [containRect]);
|
|
971
|
+
|
|
972
|
+
const segmentRunIdRef = useRef(0);
|
|
973
|
+
const lastSegmentKeyRef = useRef('');
|
|
974
|
+
const segmentInFlightKeyRef = useRef('');
|
|
975
|
+
const maskPathsContainRectRef = useRef<ContainRect | null>(null);
|
|
976
|
+
|
|
977
|
+
useEffect(() => {
|
|
978
|
+
paintResourceLayersRef.current = paintResourceLayers;
|
|
979
|
+
}, [paintResourceLayers]);
|
|
980
|
+
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
segmentsReadyRef.current = segmentsReady;
|
|
983
|
+
}, [segmentsReady]);
|
|
984
|
+
|
|
985
|
+
useEffect(() => {
|
|
986
|
+
maskPathsReadyRef.current = maskPathsReady;
|
|
987
|
+
}, [maskPathsReady]);
|
|
988
|
+
|
|
989
|
+
const emitLayersReadyIfReady = useCallback(() => {
|
|
990
|
+
if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
emitWatch('layers_ready', {
|
|
994
|
+
regionCount: regionsRef.current.length,
|
|
995
|
+
maskPathsReady: maskPathsReadyRef.current,
|
|
996
|
+
freqLayersReady: true,
|
|
997
|
+
});
|
|
998
|
+
}, [emitWatch]);
|
|
999
|
+
|
|
1000
|
+
const emitMaskPathsReadyIfReady = useCallback(() => {
|
|
1001
|
+
if (!segmentsReadyRef.current || !maskPathsReadyRef.current) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
emitWatch('mask_paths_ready', {
|
|
1005
|
+
regionCount: regionsRef.current.length,
|
|
1006
|
+
maskPathsReady: true,
|
|
1007
|
+
freqLayersReady: true,
|
|
1008
|
+
});
|
|
1009
|
+
}, [emitWatch]);
|
|
1010
|
+
|
|
1011
|
+
const emitInteractiveIfReady = useCallback(() => {
|
|
1012
|
+
if (!segmentsReadyRef.current || !paintResourceLayersRef.current) {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
emitLayersReadyIfReady();
|
|
1016
|
+
emitWatch('interactive', {
|
|
1017
|
+
regionCount: regionsRef.current.length,
|
|
1018
|
+
maskPathsReady: maskPathsReadyRef.current,
|
|
1019
|
+
freqLayersReady: true,
|
|
1020
|
+
});
|
|
1021
|
+
setCanvasInteractive(true);
|
|
1022
|
+
}, [emitWatch, emitLayersReadyIfReady]);
|
|
1023
|
+
|
|
1024
|
+
const paintColorMapSkImg = useMemo(() => {
|
|
1025
|
+
const pick = regionPickRef.current;
|
|
1026
|
+
paintColorMapSkImgRef.current?.dispose();
|
|
1027
|
+
// Early out: no pick buffer yet, or no regions have been painted (initial load or fresh session).
|
|
1028
|
+
// Avoids repeated full-resolution RGBA allocation + boxBlur (for maskFeather) + Skia.Image.MakeImage
|
|
1029
|
+
// on every re-render during the hot init path. When initialSession restores paints, paintedRegions
|
|
1030
|
+
// will update and legitimately trigger a single build of the (feathered) color map.
|
|
1031
|
+
if (!pick || paintedRegions.size === 0) {
|
|
1032
|
+
paintColorMapSkImgRef.current = null;
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
const map = createPaintColorMapForPaint(
|
|
1036
|
+
pick.buffer,
|
|
1037
|
+
pick.cols,
|
|
1038
|
+
pick.rows,
|
|
1039
|
+
paintedRegions,
|
|
1040
|
+
);
|
|
1041
|
+
paintColorMapSkImgRef.current = map;
|
|
1042
|
+
return map;
|
|
1043
|
+
}, [paintedRegions, paintResourcesReady, segmentsReady, getMaskRuntimeRevision()]);
|
|
1044
|
+
|
|
1045
|
+
const paintedRegionConfigRef = useRef(
|
|
1046
|
+
new Map<number, Record<string, unknown>>(),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
const segmentAndPrepareLayers = useCallback(
|
|
1050
|
+
async (originPath: string, maskPath: string) => {
|
|
1051
|
+
const runId = ++segmentRunIdRef.current;
|
|
1052
|
+
// isCancelled: a runId bump alone is not enough to abort if the image pair we were asked to process
|
|
1053
|
+
// is still the latest desired pair from the caller (protects against effect cleanups caused by
|
|
1054
|
+
// unrelated parent re-renders / state updates that do not change the two images).
|
|
1055
|
+
const isCancelled = () => {
|
|
1056
|
+
if (runId === segmentRunIdRef.current) return false;
|
|
1057
|
+
const desiredOrigin = latestOriginPathRef.current || originPath;
|
|
1058
|
+
const desiredMask = latestMaskPathRef.current || maskPath;
|
|
1059
|
+
const stillWanted = desiredOrigin === originPath && desiredMask === maskPath;
|
|
1060
|
+
return !stillWanted;
|
|
1061
|
+
};
|
|
1062
|
+
const pipeline = getMaskSegmentRuntimeConfig().pipeline;
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
watchStartRef.current = performance.now();
|
|
1066
|
+
lastWatchStateRef.current = null;
|
|
1067
|
+
lastWatchSignatureRef.current = null;
|
|
1068
|
+
emitWatch('init');
|
|
1069
|
+
|
|
1070
|
+
timeLog('▶ start segmentation');
|
|
1071
|
+
segmentsReadyRef.current = false;
|
|
1072
|
+
setSegmentsReady(false);
|
|
1073
|
+
setSegError('');
|
|
1074
|
+
setActiveBrushIndex(null);
|
|
1075
|
+
const clearedPainted = new Map<number, BgrColor>();
|
|
1076
|
+
paintedRegionsRef.current = clearedPainted;
|
|
1077
|
+
setPaintedRegions(clearedPainted);
|
|
1078
|
+
setPaintHistory([]);
|
|
1079
|
+
setHeldRegionId(null);
|
|
1080
|
+
setHeldRegionAnchor(null);
|
|
1081
|
+
setInitFlashRegionId(null);
|
|
1082
|
+
initFlashActiveRef.current = false;
|
|
1083
|
+
initFlashIndexRef.current = 0;
|
|
1084
|
+
initFlashListRef.current = [];
|
|
1085
|
+
if (initFlashTimerRef.current) {
|
|
1086
|
+
clearTimeout(initFlashTimerRef.current);
|
|
1087
|
+
initFlashTimerRef.current = null;
|
|
1088
|
+
}
|
|
1089
|
+
hasAppliedInitialSessionRef.current = false;
|
|
1090
|
+
lastExportCacheRef.current = null;
|
|
1091
|
+
if (autoExportDebounceRef.current) {
|
|
1092
|
+
clearTimeout(autoExportDebounceRef.current);
|
|
1093
|
+
autoExportDebounceRef.current = null;
|
|
1094
|
+
}
|
|
1095
|
+
exportInFlightRef.current = false;
|
|
1096
|
+
regionsRef.current = [];
|
|
1097
|
+
maskPickRef.current = null;
|
|
1098
|
+
regionPickRef.current = null;
|
|
1099
|
+
regionMaskDataRef.current = null;
|
|
1100
|
+
workBufferRef.current = null;
|
|
1101
|
+
paintLayersPromiseRef.current = null;
|
|
1102
|
+
setRegionOutlinePaths(new Map());
|
|
1103
|
+
setMaskPathsReady(false);
|
|
1104
|
+
setPaintResourcesReady(false);
|
|
1105
|
+
setLayersLoading(false);
|
|
1106
|
+
baseboardPickMaskRef.current = null;
|
|
1107
|
+
kickRegionIdRef.current = null;
|
|
1108
|
+
maskPathsContainRectRef.current = null;
|
|
1109
|
+
setRegionPalette([]);
|
|
1110
|
+
setRegionCount(0);
|
|
1111
|
+
// Reset high-res snapshot state so a fresh segmentation starts clean (no stale size/ref).
|
|
1112
|
+
setExportCanvasSize(null);
|
|
1113
|
+
setHighResSnapshotEnabled(false);
|
|
1114
|
+
|
|
1115
|
+
const prevLayers = paintResourceLayersRef.current;
|
|
1116
|
+
if (prevLayers) {
|
|
1117
|
+
releasePaintResourceLayers(prevLayers);
|
|
1118
|
+
paintResourceLayersRef.current = null;
|
|
1119
|
+
setPaintResourceLayers(null);
|
|
1120
|
+
}
|
|
1121
|
+
releaseOriginSkImage(originSkImgRef.current);
|
|
1122
|
+
originSkImgRef.current = null;
|
|
1123
|
+
setOriginSkImg(null);
|
|
1124
|
+
|
|
1125
|
+
try {
|
|
1126
|
+
timeLog('▶ reading origin PNG');
|
|
1127
|
+
const originPromise = readPngBgrBuffer(originPath);
|
|
1128
|
+
timeLog('▶ reading mask PNG');
|
|
1129
|
+
const maskPromise = readPngBgrBuffer(maskPath);
|
|
1130
|
+
|
|
1131
|
+
const originDecoded = await originPromise;
|
|
1132
|
+
const afterOriginCancelled = isCancelled();
|
|
1133
|
+
if (afterOriginCancelled) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const imgW = originDecoded.cols;
|
|
1138
|
+
const imgH = originDecoded.rows;
|
|
1139
|
+
setImageSize({ w: imgW, h: imgH });
|
|
1140
|
+
|
|
1141
|
+
let scale = 1;
|
|
1142
|
+
if (Math.max(imgW, imgH) > pipeline.maxImageLongSide) {
|
|
1143
|
+
scale = pipeline.maxImageLongSide / Math.max(imgW, imgH);
|
|
1144
|
+
}
|
|
1145
|
+
const segW = Math.floor(imgW * scale);
|
|
1146
|
+
const segH = Math.floor(imgH * scale);
|
|
1147
|
+
const minArea = pipeline.minContourArea * scale * scale;
|
|
1148
|
+
|
|
1149
|
+
const workScaledTask = prepareWorkScaledBgrBuffer(
|
|
1150
|
+
originDecoded.buffer,
|
|
1151
|
+
imgW,
|
|
1152
|
+
imgH,
|
|
1153
|
+
scale,
|
|
1154
|
+
).then(workScaled => {
|
|
1155
|
+
if (isCancelled()) {
|
|
1156
|
+
return workScaled;
|
|
1157
|
+
}
|
|
1158
|
+
workBufferRef.current = workScaled;
|
|
1159
|
+
setExportCanvasSize({ w: workScaled.cols, h: workScaled.rows });
|
|
1160
|
+
// Enable the offscreen high-res export canvas as soon as we know the work resolution.
|
|
1161
|
+
// This gives the hidden <Canvas> time to mount and commit before autoExport/save()
|
|
1162
|
+
// tries to snapshot it. The inner renderFullResPainted() will draw cheap origin until
|
|
1163
|
+
// paints + shader textures are ready. This greatly increases the chance that the
|
|
1164
|
+
// preferred makeImageSnapshot path succeeds for rich exports (instead of falling
|
|
1165
|
+
// through to the drawAsImage reconstruction).
|
|
1166
|
+
setHighResSnapshotEnabled(true);
|
|
1167
|
+
void loadPaintLayersRef.current();
|
|
1168
|
+
return workScaled;
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const segmentTask = maskPromise.then(async maskDecoded => {
|
|
1172
|
+
if (isCancelled()) {
|
|
1173
|
+
throw new Error('cancelled');
|
|
1174
|
+
}
|
|
1175
|
+
timeLog('▶ PNG read completed');
|
|
1176
|
+
emitWatch('images_loaded');
|
|
1177
|
+
timeLog(`▶ image size: ${imgW}x${imgH}`);
|
|
1178
|
+
|
|
1179
|
+
const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
|
|
1180
|
+
const segMaskBuffer = resizeBgrBuffer(
|
|
1181
|
+
maskBuffer,
|
|
1182
|
+
maskW,
|
|
1183
|
+
maskH,
|
|
1184
|
+
segW,
|
|
1185
|
+
segH,
|
|
1186
|
+
);
|
|
1187
|
+
timeLog(`▶ mask scale: ${scale.toFixed(3)}`);
|
|
1188
|
+
emitWatch('mask_aligned');
|
|
1189
|
+
return extractRegionsFromMaskBufferSync(segMaskBuffer, segW, segH, {
|
|
1190
|
+
minArea,
|
|
1191
|
+
approxEpsilon: pipeline.contourApproxEpsilon,
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
void maskPromise.then(async maskDecoded => {
|
|
1196
|
+
const { buffer: maskBuffer, cols: maskW, rows: maskH } = maskDecoded;
|
|
1197
|
+
let pickBuffer: Uint8Array;
|
|
1198
|
+
if (maskW !== imgW || maskH !== imgH) {
|
|
1199
|
+
pickBuffer = await cv.resizeBgrBuffer(
|
|
1200
|
+
maskBuffer,
|
|
1201
|
+
maskW,
|
|
1202
|
+
maskH,
|
|
1203
|
+
imgW,
|
|
1204
|
+
imgH,
|
|
1205
|
+
);
|
|
1206
|
+
} else {
|
|
1207
|
+
pickBuffer = new Uint8Array(maskBuffer);
|
|
1208
|
+
}
|
|
1209
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
maskPickRef.current = {
|
|
1213
|
+
buffer: pickBuffer,
|
|
1214
|
+
cols: imgW,
|
|
1215
|
+
rows: imgH,
|
|
1216
|
+
};
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
const [segmentResult] = await Promise.all([
|
|
1220
|
+
segmentTask,
|
|
1221
|
+
workScaledTask,
|
|
1222
|
+
]);
|
|
1223
|
+
if (isCancelled()) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const paintPromise =
|
|
1227
|
+
paintLayersPromiseRef.current ?? Promise.resolve();
|
|
1228
|
+
emitWatch('mask_sampled', { regionCount: segmentResult.regions.length });
|
|
1229
|
+
timeLog(`▶ segmentation completed, valid regions: ${segmentResult.regions.length}`);
|
|
1230
|
+
const validRegions = segmentResult.regions;
|
|
1231
|
+
if (__DEV__ && validRegions.length === 0) {
|
|
1232
|
+
console.warn(
|
|
1233
|
+
'[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image',
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
let finalRegions = validRegions;
|
|
1238
|
+
if (finalRegions.length > pipeline.maxRegions) {
|
|
1239
|
+
finalRegions = finalRegions.slice(0, pipeline.maxRegions);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
regionsRef.current = finalRegions;
|
|
1243
|
+
regionPickRef.current = segmentResult.pickMap;
|
|
1244
|
+
regionMaskDataRef.current = {
|
|
1245
|
+
labels: segmentResult.labels,
|
|
1246
|
+
baseboardBinary: segmentResult.baseboardBinary,
|
|
1247
|
+
cols: segmentResult.segCols,
|
|
1248
|
+
rows: segmentResult.segRows,
|
|
1249
|
+
};
|
|
1250
|
+
baseboardPickMaskRef.current = null;
|
|
1251
|
+
kickRegionIdRef.current =
|
|
1252
|
+
finalRegions.find(reg => reg.thinStrip)?.id ?? null;
|
|
1253
|
+
|
|
1254
|
+
const pathMapRect = getContainRect(canvasWRef.current, canvasHRef.current, imgW, imgH);
|
|
1255
|
+
maskPathsContainRectRef.current = pathMapRect;
|
|
1256
|
+
setRegionOutlinePaths(new Map());
|
|
1257
|
+
setMaskPathsReady(false);
|
|
1258
|
+
setRegionPalette(finalRegions);
|
|
1259
|
+
setRegionCount(finalRegions.length);
|
|
1260
|
+
|
|
1261
|
+
lastSegmentKeyRef.current = `${originPath}|${maskPath}`;
|
|
1262
|
+
segmentsReadyRef.current = true;
|
|
1263
|
+
setSegmentsReady(true);
|
|
1264
|
+
emitWatch('regions_ready', { regionCount: finalRegions.length });
|
|
1265
|
+
emitInteractiveIfReady();
|
|
1266
|
+
|
|
1267
|
+
void (async () => {
|
|
1268
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const pathMaskData = downsampleMaskDataForPaths(
|
|
1272
|
+
regionMaskDataRef.current!,
|
|
1273
|
+
pipeline.maskPathMaxLongSide,
|
|
1274
|
+
);
|
|
1275
|
+
const outlines = buildAllRegionOutlinePaths(
|
|
1276
|
+
finalRegions,
|
|
1277
|
+
pathMaskData,
|
|
1278
|
+
pathMapRect,
|
|
1279
|
+
);
|
|
1280
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
setRegionOutlinePaths(outlines);
|
|
1284
|
+
setMaskPathsReady(true);
|
|
1285
|
+
maskPathsReadyRef.current = true;
|
|
1286
|
+
emitMaskPathsReadyIfReady();
|
|
1287
|
+
})();
|
|
1288
|
+
|
|
1289
|
+
void (async () => {
|
|
1290
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
await paintPromise;
|
|
1294
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
baseboardPickMaskRef.current = upscaleBinaryMask(
|
|
1298
|
+
segmentResult.baseboardBinary,
|
|
1299
|
+
segW,
|
|
1300
|
+
segH,
|
|
1301
|
+
imgW,
|
|
1302
|
+
imgH,
|
|
1303
|
+
);
|
|
1304
|
+
})();
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1307
|
+
if (!isCancelled()) {
|
|
1308
|
+
console.error('[SDK-SEGMENT] segmentation failed', e);
|
|
1309
|
+
setSegError(msg);
|
|
1310
|
+
reportError(msg, e);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
[emitWatch, emitInteractiveIfReady, emitMaskPathsReadyIfReady, reportError],
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
const loadPaintLayersIfNeeded = useCallback(() => {
|
|
1318
|
+
if (paintResourcesReady) {
|
|
1319
|
+
return Promise.resolve();
|
|
1320
|
+
}
|
|
1321
|
+
if (paintLayersPromiseRef.current) {
|
|
1322
|
+
return paintLayersPromiseRef.current;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const work = workBufferRef.current;
|
|
1326
|
+
if (!work) {
|
|
1327
|
+
return Promise.resolve();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const runId = segmentRunIdRef.current;
|
|
1331
|
+
let promise!: Promise<void>;
|
|
1332
|
+
promise = (async () => {
|
|
1333
|
+
setLayersLoading(true);
|
|
1334
|
+
timeLog('▶ start loading paint shader textures');
|
|
1335
|
+
try {
|
|
1336
|
+
const result = await preparePaintResourcesFromWorkBuffer(
|
|
1337
|
+
work.buffer,
|
|
1338
|
+
work.cols,
|
|
1339
|
+
work.rows,
|
|
1340
|
+
layers => {
|
|
1341
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1342
|
+
releaseFreqLayerImages(layers);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
setPaintResourceLayers(layers);
|
|
1346
|
+
paintResourceLayersRef.current = layers;
|
|
1347
|
+
setPaintResourcesReady(true);
|
|
1348
|
+
timeLog('▶ paint shader textures ready');
|
|
1349
|
+
emitInteractiveIfReady();
|
|
1350
|
+
},
|
|
1351
|
+
);
|
|
1352
|
+
if (runId !== segmentRunIdRef.current) {
|
|
1353
|
+
if (result) {
|
|
1354
|
+
result.originImage.dispose();
|
|
1355
|
+
releasePaintResourceLayers(result.layers);
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (!result) {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
releaseOriginSkImage(originSkImgRef.current);
|
|
1363
|
+
originSkImgRef.current = result.originImage;
|
|
1364
|
+
setOriginSkImg(result.originImage);
|
|
1365
|
+
timeLog('▶ origin Skia work resolution');
|
|
1366
|
+
if (!paintResourceLayersRef.current) {
|
|
1367
|
+
setPaintResourceLayers(result.layers);
|
|
1368
|
+
paintResourceLayersRef.current = result.layers;
|
|
1369
|
+
setPaintResourcesReady(true);
|
|
1370
|
+
}
|
|
1371
|
+
emitInteractiveIfReady();
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
if (__DEV__) {
|
|
1374
|
+
console.warn('[MaskSegment] failed to prepare paint shader textures', error);
|
|
1375
|
+
}
|
|
1376
|
+
} finally {
|
|
1377
|
+
setLayersLoading(false);
|
|
1378
|
+
if (paintLayersPromiseRef.current === promise) {
|
|
1379
|
+
paintLayersPromiseRef.current = null;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
})();
|
|
1383
|
+
paintLayersPromiseRef.current = promise;
|
|
1384
|
+
return promise;
|
|
1385
|
+
}, [emitInteractiveIfReady, paintResourcesReady]);
|
|
1386
|
+
|
|
1387
|
+
loadPaintLayersRef.current = loadPaintLayersIfNeeded;
|
|
1388
|
+
|
|
1389
|
+
const pickOriginImage = async () => {
|
|
1390
|
+
const res = await launchImageLibrary({ mediaType: 'photo' });
|
|
1391
|
+
const uri = res.assets?.[0]?.uri;
|
|
1392
|
+
if (!uri) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const pngPath = await cv.ensurePngPath(uri, `picked_origin_${Date.now()}.png`);
|
|
1396
|
+
setOriginImgPath(pngPath);
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
const pickMaskImage = async () => {
|
|
1400
|
+
const res = await launchImageLibrary({ mediaType: 'photo' });
|
|
1401
|
+
const uri = res.assets?.[0]?.uri;
|
|
1402
|
+
if (!uri) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const pngPath = await cv.ensurePngPath(uri, `picked_mask_${Date.now()}.png`);
|
|
1406
|
+
setMaskImgPath(pngPath);
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
const clearCacheAndResegment = useCallback(async () => {
|
|
1410
|
+
if (isRefreshing || !originImgPath || !maskImgPath) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
setIsRefreshing(true);
|
|
1415
|
+
try {
|
|
1416
|
+
resetZoom();
|
|
1417
|
+
const layers = paintResourceLayersRef.current;
|
|
1418
|
+
if (layers) {
|
|
1419
|
+
releasePaintResourceLayers(layers);
|
|
1420
|
+
paintResourceLayersRef.current = null;
|
|
1421
|
+
setPaintResourceLayers(null);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
await clearDerivedImageCache();
|
|
1425
|
+
lastSegmentKeyRef.current = '';
|
|
1426
|
+
await segmentAndPrepareLayers(originImgPath, maskImgPath);
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1429
|
+
setSegError(msg);
|
|
1430
|
+
reportError(msg, e);
|
|
1431
|
+
} finally {
|
|
1432
|
+
setIsRefreshing(false);
|
|
1433
|
+
}
|
|
1434
|
+
}, [
|
|
1435
|
+
isRefreshing,
|
|
1436
|
+
originImgPath,
|
|
1437
|
+
maskImgPath,
|
|
1438
|
+
segmentAndPrepareLayers,
|
|
1439
|
+
reportError,
|
|
1440
|
+
resetZoom,
|
|
1441
|
+
]);
|
|
1442
|
+
|
|
1443
|
+
useEffect(() => {
|
|
1444
|
+
if (!originImgPath || !maskImgPath) {
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const segmentKey = `${originImgPath}|${maskImgPath}`;
|
|
1449
|
+
if (
|
|
1450
|
+
lastSegmentKeyRef.current === segmentKey ||
|
|
1451
|
+
segmentInFlightKeyRef.current === segmentKey
|
|
1452
|
+
) {
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
segmentInFlightKeyRef.current = segmentKey;
|
|
1457
|
+
void segmentAndPrepareLayers(originImgPath, maskImgPath).finally(() => {
|
|
1458
|
+
if (segmentInFlightKeyRef.current === segmentKey) {
|
|
1459
|
+
segmentInFlightKeyRef.current = '';
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
return () => {
|
|
1464
|
+
segmentRunIdRef.current += 1;
|
|
1465
|
+
if (segmentInFlightKeyRef.current === segmentKey) {
|
|
1466
|
+
segmentInFlightKeyRef.current = '';
|
|
1467
|
+
}
|
|
1468
|
+
const layers = paintResourceLayersRef.current;
|
|
1469
|
+
if (layers) {
|
|
1470
|
+
releasePaintResourceLayers(layers);
|
|
1471
|
+
paintResourceLayersRef.current = null;
|
|
1472
|
+
}
|
|
1473
|
+
paintColorMapSkImgRef.current?.dispose();
|
|
1474
|
+
paintColorMapSkImgRef.current = null;
|
|
1475
|
+
regionsRef.current = [];
|
|
1476
|
+
// Also reset any pending init flash state (the full reset will also run at start of
|
|
1477
|
+
// the next segmentAndPrepareLayers, but this keeps things clean if the effect
|
|
1478
|
+
// re-triggers segmentation while a flash sequence was in flight).
|
|
1479
|
+
initFlashListRef.current = [];
|
|
1480
|
+
initFlashActiveRef.current = false;
|
|
1481
|
+
initFlashIndexRef.current = 0;
|
|
1482
|
+
if (initFlashTimerRef.current) {
|
|
1483
|
+
clearTimeout(initFlashTimerRef.current);
|
|
1484
|
+
initFlashTimerRef.current = null;
|
|
1485
|
+
}
|
|
1486
|
+
setInitFlashRegionId(null);
|
|
1487
|
+
};
|
|
1488
|
+
}, [originImgPath, maskImgPath]);
|
|
1489
|
+
|
|
1490
|
+
const buildPaintedRecords = useCallback((): PaintedRegionRecord[] => {
|
|
1491
|
+
const records: PaintedRegionRecord[] = [];
|
|
1492
|
+
// Prefer the live ref so getPaintedRegions() / session() used by host for
|
|
1493
|
+
// colorParams at save time sees the exact same data as the save() composite.
|
|
1494
|
+
const src = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
|
|
1495
|
+
? paintedRegionsRef.current
|
|
1496
|
+
: paintedRegions;
|
|
1497
|
+
for (const [regionId, color] of src) {
|
|
1498
|
+
const region = regionsRef.current.find(reg => reg.id === regionId);
|
|
1499
|
+
records.push({
|
|
1500
|
+
regionId,
|
|
1501
|
+
regionName: region?.name ?? String(regionId),
|
|
1502
|
+
color,
|
|
1503
|
+
configJson: paintedRegionConfigRef.current.get(regionId),
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
return records;
|
|
1507
|
+
}, [paintedRegions]);
|
|
1508
|
+
|
|
1509
|
+
const restoreSession = useCallback((session: MaskSegmentSession) => {
|
|
1510
|
+
const nextPainted = new Map<number, BgrColor>();
|
|
1511
|
+
paintedRegionConfigRef.current = new Map();
|
|
1512
|
+
|
|
1513
|
+
const currentRegions = regionsRef.current || [];
|
|
1514
|
+
const nameToRealId = new Map<string, number>();
|
|
1515
|
+
for (const r of currentRegions) {
|
|
1516
|
+
if (r && r.name) {
|
|
1517
|
+
const key = String(r.name).trim().toLowerCase();
|
|
1518
|
+
if (key && !nameToRealId.has(key)) {
|
|
1519
|
+
nameToRealId.set(key, r.id);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Build oldId -> regionName from the incoming seed (for paintHistory remapping).
|
|
1525
|
+
const oldIdToName = new Map<number, string>();
|
|
1526
|
+
for (const item of session.painted || []) {
|
|
1527
|
+
if (item && typeof item.regionId === 'number' && item.regionName) {
|
|
1528
|
+
oldIdToName.set(item.regionId, String(item.regionName));
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Resolve directly by name. Name is unique within a segmentation so there is no
|
|
1533
|
+
// need for id-based heuristics — the region name is the authoritative identity.
|
|
1534
|
+
for (const item of session.painted || []) {
|
|
1535
|
+
if (!item) continue;
|
|
1536
|
+
let targetId = item.regionId;
|
|
1537
|
+
|
|
1538
|
+
if (item.regionName) {
|
|
1539
|
+
const key = String(item.regionName).trim().toLowerCase();
|
|
1540
|
+
const realId = nameToRealId.get(key);
|
|
1541
|
+
if (typeof realId === 'number') {
|
|
1542
|
+
targetId = realId;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
nextPainted.set(targetId, item.color);
|
|
1547
|
+
if (item.configJson) {
|
|
1548
|
+
paintedRegionConfigRef.current.set(targetId, item.configJson);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Remap paintHistory via names.
|
|
1553
|
+
const resolvedHistory: number[] = [];
|
|
1554
|
+
for (const oldId of session.paintHistory || []) {
|
|
1555
|
+
if (typeof oldId !== 'number') continue;
|
|
1556
|
+
const nm = oldIdToName.get(oldId);
|
|
1557
|
+
if (nm) {
|
|
1558
|
+
const real = nameToRealId.get(String(nm).trim().toLowerCase());
|
|
1559
|
+
if (typeof real === 'number') {
|
|
1560
|
+
resolvedHistory.push(real);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// Fallback: keep oldId if it happens to be valid in current segmentation.
|
|
1565
|
+
if (currentRegions.some((r) => r && r.id === oldId)) {
|
|
1566
|
+
resolvedHistory.push(oldId);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
paintedRegionsRef.current = nextPainted;
|
|
1571
|
+
setPaintedRegions(nextPainted);
|
|
1572
|
+
setPaintHistory(resolvedHistory.length ? resolvedHistory : [...(session.paintHistory || [])]);
|
|
1573
|
+
|
|
1574
|
+
if (session.currentColor) {
|
|
1575
|
+
setCustomPaintColor(session.currentColor);
|
|
1576
|
+
customPaintConfigJsonRef.current = session.currentColorConfigJson;
|
|
1577
|
+
setActiveBrushIndex(null);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
}, []);
|
|
1581
|
+
|
|
1582
|
+
useEffect(() => {
|
|
1583
|
+
if (!initialSession || !segmentsReady) {
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
if (hasAppliedInitialSessionRef.current) {
|
|
1587
|
+
// Already seeded once for this canvas instance / segmentation. Do not re-apply on
|
|
1588
|
+
// subsequent initialSession prop changes (host may pass new object refs when its
|
|
1589
|
+
// vizSlots or selected brush changes). Re-applying would reset paintedRegions to
|
|
1590
|
+
// whatever the (often partial) seed snapshot contains.
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
hasAppliedInitialSessionRef.current = true;
|
|
1594
|
+
restoreSession(initialSession);
|
|
1595
|
+
}, [initialSession, segmentsReady, restoreSession]);
|
|
1596
|
+
|
|
1597
|
+
useEffect(() => {
|
|
1598
|
+
return () => {
|
|
1599
|
+
if (initFlashTimerRef.current) {
|
|
1600
|
+
clearTimeout(initFlashTimerRef.current);
|
|
1601
|
+
}
|
|
1602
|
+
};
|
|
1603
|
+
}, []);
|
|
1604
|
+
|
|
1605
|
+
const stopInitRegionFlash = useCallback(() => {
|
|
1606
|
+
initFlashActiveRef.current = false;
|
|
1607
|
+
if (initFlashTimerRef.current) {
|
|
1608
|
+
clearTimeout(initFlashTimerRef.current);
|
|
1609
|
+
initFlashTimerRef.current = null;
|
|
1610
|
+
}
|
|
1611
|
+
setInitFlashRegionId(null);
|
|
1612
|
+
}, []);
|
|
1613
|
+
|
|
1614
|
+
const startInitRegionFlashLoop = useCallback(() => {
|
|
1615
|
+
const ir = getMaskSegmentRuntimeConfig().interaction;
|
|
1616
|
+
if (!ir.enableInitRegionFlash) {
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
if (initFlashActiveRef.current) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const allRegions = regionsRef.current;
|
|
1623
|
+
if (allRegions.length === 0) {
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Filter out painted regions and tiny regions (noise / thin strips
|
|
1628
|
+
// that produce negligible overlays). Threshold: 0.2% of total image area.
|
|
1629
|
+
const imgSize = imageSizeRef2.current;
|
|
1630
|
+
const minFlashArea = imgSize
|
|
1631
|
+
? Math.max(500, imgSize.w * imgSize.h * 0.002)
|
|
1632
|
+
: 500;
|
|
1633
|
+
initFlashListRef.current = allRegions.filter(
|
|
1634
|
+
(r) =>
|
|
1635
|
+
!paintedRegionsRef.current.has(r.id) && r.area >= minFlashArea,
|
|
1636
|
+
);
|
|
1637
|
+
initFlashActiveRef.current = true;
|
|
1638
|
+
initFlashIndexRef.current = 0;
|
|
1639
|
+
|
|
1640
|
+
const showNext = () => {
|
|
1641
|
+
if (!initFlashActiveRef.current || initFlashListRef.current.length === 0) {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const list = initFlashListRef.current;
|
|
1645
|
+
const idx = initFlashIndexRef.current;
|
|
1646
|
+
if (idx >= list.length) {
|
|
1647
|
+
// One full pass of dashed outline flashes (one per *unpainted* region) is enough.
|
|
1648
|
+
// Stop automatically; onUserInteraction can stop early.
|
|
1649
|
+
stopInitRegionFlash();
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
setInitFlashRegionId(list[idx].id);
|
|
1653
|
+
initFlashIndexRef.current += 1;
|
|
1654
|
+
initFlashTimerRef.current = setTimeout(
|
|
1655
|
+
showNext,
|
|
1656
|
+
ir.initRegionFlashMs,
|
|
1657
|
+
);
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
showNext();
|
|
1661
|
+
}, []);
|
|
1662
|
+
|
|
1663
|
+
const onUserInteraction = useCallback(() => {
|
|
1664
|
+
stopInitRegionFlash();
|
|
1665
|
+
}, [stopInitRegionFlash]);
|
|
1666
|
+
|
|
1667
|
+
// Once any region has been painted, stop the entire discovery flash immediately.
|
|
1668
|
+
// No individual pruning — it's all or nothing: either 0 painted → cycle through
|
|
1669
|
+
// all regions, or ≥1 painted → stop flashing entirely.
|
|
1670
|
+
useEffect(() => {
|
|
1671
|
+
if (!initFlashActiveRef.current) return;
|
|
1672
|
+
if (paintedRegionsRef.current.size > 0) {
|
|
1673
|
+
stopInitRegionFlash();
|
|
1674
|
+
}
|
|
1675
|
+
}, [paintedRegions, stopInitRegionFlash]);
|
|
1676
|
+
|
|
1677
|
+
useEffect(() => {
|
|
1678
|
+
if (segmentsReady && maskPathsReady && containRect && regionCount > 0 && canvasInteractive) {
|
|
1679
|
+
if (!initFlashActiveRef.current) {
|
|
1680
|
+
startInitRegionFlashLoop();
|
|
1681
|
+
}
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
if (!segmentsReady) {
|
|
1685
|
+
stopInitRegionFlash();
|
|
1686
|
+
setCanvasInteractive(false);
|
|
1687
|
+
}
|
|
1688
|
+
}, [
|
|
1689
|
+
segmentsReady,
|
|
1690
|
+
maskPathsReady,
|
|
1691
|
+
containRect,
|
|
1692
|
+
regionCount,
|
|
1693
|
+
canvasInteractive,
|
|
1694
|
+
startInitRegionFlashLoop,
|
|
1695
|
+
stopInitRegionFlash,
|
|
1696
|
+
]);
|
|
1697
|
+
|
|
1698
|
+
const getActiveBrushColor = useCallback((): BgrColor | null => {
|
|
1699
|
+
if (customPaintColor) {
|
|
1700
|
+
return customPaintColor;
|
|
1701
|
+
}
|
|
1702
|
+
if (activeBrushIndex == null) {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
return paintPalette[activeBrushIndex] ?? null;
|
|
1706
|
+
}, [customPaintColor, activeBrushIndex, paintPalette]);
|
|
1707
|
+
|
|
1708
|
+
const hasActiveBrush = customPaintColor != null || activeBrushIndex != null;
|
|
1709
|
+
|
|
1710
|
+
const applyPaintToRegion = useCallback(
|
|
1711
|
+
(targetRegionId: number, color: BgrColor) => {
|
|
1712
|
+
let applied = false;
|
|
1713
|
+
setPaintedRegions(prev => {
|
|
1714
|
+
const existing = prev.get(targetRegionId);
|
|
1715
|
+
if (existing && bgrColorEquals(existing, color)) {
|
|
1716
|
+
return prev;
|
|
1717
|
+
}
|
|
1718
|
+
applied = true;
|
|
1719
|
+
setPaintHistory(history => {
|
|
1720
|
+
const last = history[history.length - 1];
|
|
1721
|
+
if (last === targetRegionId) {
|
|
1722
|
+
return history;
|
|
1723
|
+
}
|
|
1724
|
+
return [...history.filter(id => id !== targetRegionId), targetRegionId];
|
|
1725
|
+
});
|
|
1726
|
+
const next = new Map(prev);
|
|
1727
|
+
next.set(targetRegionId, color);
|
|
1728
|
+
paintedRegionsRef.current = next;
|
|
1729
|
+
return next;
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
if (applied) {
|
|
1733
|
+
const configJson =
|
|
1734
|
+
customPaintConfigJsonRef.current ??
|
|
1735
|
+
paintedRegionConfigRef.current.get(targetRegionId);
|
|
1736
|
+
if (customPaintConfigJsonRef.current) {
|
|
1737
|
+
paintedRegionConfigRef.current.set(
|
|
1738
|
+
targetRegionId,
|
|
1739
|
+
customPaintConfigJsonRef.current,
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
const region = regionsRef.current.find(reg => reg.id === targetRegionId);
|
|
1743
|
+
onPaintCallbackRef.current?.({
|
|
1744
|
+
kind: 'painted',
|
|
1745
|
+
regionId: targetRegionId,
|
|
1746
|
+
regionName: region?.name ?? String(targetRegionId),
|
|
1747
|
+
color,
|
|
1748
|
+
configJson,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
[],
|
|
1753
|
+
);
|
|
1754
|
+
|
|
1755
|
+
const findRegionAtPoint = useCallback(
|
|
1756
|
+
(x: number, y: number, strict = false): number | null => {
|
|
1757
|
+
if (!segmentsReady || !imageSize || regionsRef.current.length === 0) {
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const norm = canvasToNormalized(
|
|
1762
|
+
x,
|
|
1763
|
+
y,
|
|
1764
|
+
canvasW,
|
|
1765
|
+
canvasH,
|
|
1766
|
+
imageSize.w,
|
|
1767
|
+
imageSize.h,
|
|
1768
|
+
);
|
|
1769
|
+
if (!norm) {
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const regionPick = regionPickRef.current;
|
|
1774
|
+
if (regionPick) {
|
|
1775
|
+
const pickHit = lookupRegionFromPickMap(
|
|
1776
|
+
norm.x,
|
|
1777
|
+
norm.y,
|
|
1778
|
+
regionPick,
|
|
1779
|
+
strict
|
|
1780
|
+
? 0
|
|
1781
|
+
: getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
|
|
1782
|
+
);
|
|
1783
|
+
if (pickHit != null) {
|
|
1784
|
+
return pickHit;
|
|
1785
|
+
}
|
|
1786
|
+
if (strict) {
|
|
1787
|
+
return null;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const pick = maskPickRef.current;
|
|
1792
|
+
const kickId = kickRegionIdRef.current;
|
|
1793
|
+
|
|
1794
|
+
if (!strict) {
|
|
1795
|
+
const polygonHit = resolveRegionHit(regionsRef.current, norm.x, norm.y);
|
|
1796
|
+
if (polygonHit != null) {
|
|
1797
|
+
return polygonHit;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if (pick && kickId != null) {
|
|
1802
|
+
const pickMask = baseboardPickMaskRef.current;
|
|
1803
|
+
const kickHit = pickKickRegionFromMask(
|
|
1804
|
+
norm.x,
|
|
1805
|
+
norm.y,
|
|
1806
|
+
pick,
|
|
1807
|
+
kickId,
|
|
1808
|
+
pickMask,
|
|
1809
|
+
strict,
|
|
1810
|
+
);
|
|
1811
|
+
if (kickHit != null) {
|
|
1812
|
+
return kickHit;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (!strict) {
|
|
1816
|
+
const kickReg = regionsRef.current.find(reg => reg.id === kickId);
|
|
1817
|
+
if (kickReg && pickKickNearStrip(norm.x, norm.y, kickReg)) {
|
|
1818
|
+
return kickId;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
return null;
|
|
1824
|
+
},
|
|
1825
|
+
[segmentsReady, imageSize, canvasW, canvasH],
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1828
|
+
const selectBrushColor = useCallback(
|
|
1829
|
+
(brushIndex: number) => {
|
|
1830
|
+
onUserInteraction();
|
|
1831
|
+
setCustomPaintColor(null);
|
|
1832
|
+
customPaintConfigJsonRef.current = undefined;
|
|
1833
|
+
setActiveBrushIndex(brushIndex);
|
|
1834
|
+
void loadPaintLayersIfNeeded();
|
|
1835
|
+
},
|
|
1836
|
+
[onUserInteraction, loadPaintLayersIfNeeded],
|
|
1837
|
+
);
|
|
1838
|
+
|
|
1839
|
+
const onCanvasTap = useCallback(
|
|
1840
|
+
(x: number, y: number) => {
|
|
1841
|
+
onUserInteraction();
|
|
1842
|
+
if (
|
|
1843
|
+
!segmentsReady ||
|
|
1844
|
+
!imageSize ||
|
|
1845
|
+
regionsRef.current.length === 0 ||
|
|
1846
|
+
!hasActiveBrush ||
|
|
1847
|
+
!paintResourcesReady ||
|
|
1848
|
+
disabled
|
|
1849
|
+
) {
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const regionId = findRegionAtPoint(x, y);
|
|
1854
|
+
if (regionId == null) {
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const brushColor = getActiveBrushColor();
|
|
1859
|
+
if (!brushColor) {
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
applyPaintToRegion(regionId, brushColor);
|
|
1864
|
+
if (!paintResourcesReady && !paintLayersPromiseRef.current) {
|
|
1865
|
+
void loadPaintLayersIfNeeded();
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
[
|
|
1869
|
+
segmentsReady,
|
|
1870
|
+
imageSize,
|
|
1871
|
+
hasActiveBrush,
|
|
1872
|
+
paintResourcesReady,
|
|
1873
|
+
disabled,
|
|
1874
|
+
findRegionAtPoint,
|
|
1875
|
+
getActiveBrushColor,
|
|
1876
|
+
applyPaintToRegion,
|
|
1877
|
+
onUserInteraction,
|
|
1878
|
+
loadPaintLayersIfNeeded,
|
|
1879
|
+
],
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
// ── Canvas-size & state refs for gesture callbacks ─────────────────────
|
|
1883
|
+
// Declared after their targets so useRef receives the current value.
|
|
1884
|
+
// canvasWRef / canvasHRef are declared earlier (before segmentAndPrepareLayers)
|
|
1885
|
+
// so the async body can read the latest layout size. Their sync useEffect is below.
|
|
1886
|
+
const hasActiveBrushRef = useRef(hasActiveBrush);
|
|
1887
|
+
const disabledRef = useRef(disabled);
|
|
1888
|
+
const segmentsReadyRef2 = useRef(segmentsReady);
|
|
1889
|
+
const imageSizeRef2 = useRef(imageSize);
|
|
1890
|
+
useEffect(() => { canvasWRef.current = canvasW; }, [canvasW]);
|
|
1891
|
+
useEffect(() => { canvasHRef.current = canvasH; }, [canvasH]);
|
|
1892
|
+
useEffect(() => { hasActiveBrushRef.current = hasActiveBrush; }, [hasActiveBrush]);
|
|
1893
|
+
useEffect(() => { disabledRef.current = disabled; }, [disabled]);
|
|
1894
|
+
useEffect(() => { segmentsReadyRef2.current = segmentsReady; }, [segmentsReady]);
|
|
1895
|
+
useEffect(() => { imageSizeRef2.current = imageSize; }, [imageSize]);
|
|
1896
|
+
|
|
1897
|
+
// Stable refs for functions called inside gesture closures
|
|
1898
|
+
const findRegionAtPointRef = useRef(findRegionAtPoint);
|
|
1899
|
+
const onCanvasTapRef = useRef(onCanvasTap);
|
|
1900
|
+
useEffect(() => { findRegionAtPointRef.current = findRegionAtPoint; }, [findRegionAtPoint]);
|
|
1901
|
+
useEffect(() => { onCanvasTapRef.current = onCanvasTap; }, [onCanvasTap]);
|
|
1902
|
+
|
|
1903
|
+
const undoSelection = useCallback(() => {
|
|
1904
|
+
onUserInteraction();
|
|
1905
|
+
setPaintHistory(history => {
|
|
1906
|
+
if (history.length === 0) {
|
|
1907
|
+
return history;
|
|
1908
|
+
}
|
|
1909
|
+
const lastId = history[history.length - 1];
|
|
1910
|
+
setPaintedRegions(prev => {
|
|
1911
|
+
const next = new Map(prev);
|
|
1912
|
+
next.delete(lastId);
|
|
1913
|
+
paintedRegionsRef.current = next; // sync for imperative readers
|
|
1914
|
+
return next;
|
|
1915
|
+
});
|
|
1916
|
+
paintedRegionConfigRef.current.delete(lastId);
|
|
1917
|
+
return history.slice(0, -1);
|
|
1918
|
+
});
|
|
1919
|
+
}, [onUserInteraction]);
|
|
1920
|
+
|
|
1921
|
+
const clearAllPaint = useCallback(() => {
|
|
1922
|
+
onUserInteraction();
|
|
1923
|
+
const cleared = new Map<number, BgrColor>();
|
|
1924
|
+
paintedRegionsRef.current = cleared;
|
|
1925
|
+
setPaintedRegions(cleared);
|
|
1926
|
+
setPaintHistory([]);
|
|
1927
|
+
paintedRegionConfigRef.current = new Map();
|
|
1928
|
+
lastExportCacheRef.current = null;
|
|
1929
|
+
resetZoom();
|
|
1930
|
+
}, [onUserInteraction, resetZoom]);
|
|
1931
|
+
|
|
1932
|
+
const captureHighResExportPngBase64 = useCallback(async (): Promise<string | undefined> => {
|
|
1933
|
+
try {
|
|
1934
|
+
const c = highResExportCanvasRef.current;
|
|
1935
|
+
const sz = exportCanvasSize;
|
|
1936
|
+
if (!c || !sz) {return undefined;}
|
|
1937
|
+
let snap = c.makeImageSnapshot?.();
|
|
1938
|
+
if (!snap) {
|
|
1939
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
1940
|
+
snap = c.makeImageSnapshot?.();
|
|
1941
|
+
}
|
|
1942
|
+
if (!snap) {return undefined;}
|
|
1943
|
+
const enc = (snap as { encodeToBase64?: () => string }).encodeToBase64;
|
|
1944
|
+
if (typeof enc !== 'function') {return undefined;}
|
|
1945
|
+
const b64 = enc.call(snap) || '';
|
|
1946
|
+
return b64.length > 0 ? b64 : undefined;
|
|
1947
|
+
} catch (e) {
|
|
1948
|
+
console.warn('[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:', e);
|
|
1949
|
+
return undefined;
|
|
1950
|
+
}
|
|
1951
|
+
}, [exportCanvasSize]);
|
|
1952
|
+
|
|
1953
|
+
const runExportPipeline = useCallback(async (
|
|
1954
|
+
livePainted: Map<number, BgrColor>,
|
|
1955
|
+
destDir?: string,
|
|
1956
|
+
): Promise<SavePaintResult> => {
|
|
1957
|
+
const work = workBufferRef.current;
|
|
1958
|
+
const pick = regionPickRef.current;
|
|
1959
|
+
if (!work || !pick) {
|
|
1960
|
+
throw new Error('image not ready, cannot save');
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
let snapshotPngBase64: string | undefined;
|
|
1964
|
+
if (livePainted.size > 0) {
|
|
1965
|
+
snapshotPngBase64 = await captureHighResExportPngBase64();
|
|
1966
|
+
}
|
|
1967
|
+
const layers = paintResourceLayersRef.current;
|
|
1968
|
+
const map = paintColorMapSkImgRef.current;
|
|
1969
|
+
const originForExport = originSkImgRef.current ?? layers?.lowFreqImage ?? null;
|
|
1970
|
+
const shaderTextures = !snapshotPngBase64 && originForExport && layers && map && paintResourcesReady
|
|
1971
|
+
? {
|
|
1972
|
+
originImage: originForExport,
|
|
1973
|
+
paintColorMap: map,
|
|
1974
|
+
lowFreqImage: layers.lowFreqImage,
|
|
1975
|
+
highFreqImage: layers.highFreqImage,
|
|
1976
|
+
}
|
|
1977
|
+
: undefined;
|
|
1978
|
+
|
|
1979
|
+
const result = await compositePaintedImage({
|
|
1980
|
+
originBuffer: work.buffer,
|
|
1981
|
+
cols: work.cols,
|
|
1982
|
+
rows: work.rows,
|
|
1983
|
+
pickBuffer: pick.buffer,
|
|
1984
|
+
paintedRegions: livePainted,
|
|
1985
|
+
destDir,
|
|
1986
|
+
...(snapshotPngBase64 ? { exportPngBase64: snapshotPngBase64 } : {}),
|
|
1987
|
+
shaderTextures,
|
|
1988
|
+
renderWidth: work.cols,
|
|
1989
|
+
renderHeight: work.rows,
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
lastExportCacheRef.current = {
|
|
1993
|
+
fingerprint: paintedRegionsFingerprint(livePainted),
|
|
1994
|
+
result,
|
|
1995
|
+
};
|
|
1996
|
+
return result;
|
|
1997
|
+
}, [captureHighResExportPngBase64, paintResourcesReady]);
|
|
1998
|
+
|
|
1999
|
+
// Debounced background export — pre-warms the save() cache after each paint change.
|
|
2000
|
+
useEffect(() => {
|
|
2001
|
+
if (!autoExportOnReady) {return;}
|
|
2002
|
+
if (!segmentsReady || !paintResourcesReady) {return;}
|
|
2003
|
+
if (initialSession && !hasAppliedInitialSessionRef.current) {return;}
|
|
2004
|
+
if (paintedRegions.size === 0) {return;}
|
|
2005
|
+
|
|
2006
|
+
if (autoExportDebounceRef.current) {
|
|
2007
|
+
clearTimeout(autoExportDebounceRef.current);
|
|
2008
|
+
}
|
|
2009
|
+
autoExportDebounceRef.current = setTimeout(() => {
|
|
2010
|
+
autoExportDebounceRef.current = null;
|
|
2011
|
+
if (exportInFlightRef.current) {return;}
|
|
2012
|
+
const livePainted = paintedRegionsRef.current;
|
|
2013
|
+
if (!livePainted || livePainted.size === 0) {return;}
|
|
2014
|
+
|
|
2015
|
+
exportInFlightRef.current = true;
|
|
2016
|
+
void (async () => {
|
|
2017
|
+
try {
|
|
2018
|
+
const result = await runExportPipeline(livePainted);
|
|
2019
|
+
onExportedRef.current?.(result);
|
|
2020
|
+
} catch (e) {
|
|
2021
|
+
console.log('[VIZ-SAVE] debounced autoExport threw (non-fatal):', e);
|
|
2022
|
+
} finally {
|
|
2023
|
+
exportInFlightRef.current = false;
|
|
2024
|
+
}
|
|
2025
|
+
})();
|
|
2026
|
+
}, 400);
|
|
2027
|
+
|
|
2028
|
+
return () => {
|
|
2029
|
+
if (autoExportDebounceRef.current) {
|
|
2030
|
+
clearTimeout(autoExportDebounceRef.current);
|
|
2031
|
+
autoExportDebounceRef.current = null;
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
}, [
|
|
2035
|
+
autoExportOnReady,
|
|
2036
|
+
segmentsReady,
|
|
2037
|
+
paintResourcesReady,
|
|
2038
|
+
initialSession,
|
|
2039
|
+
paintedRegions,
|
|
2040
|
+
runExportPipeline,
|
|
2041
|
+
]);
|
|
2042
|
+
|
|
2043
|
+
useImperativeHandle(
|
|
2044
|
+
ref,
|
|
2045
|
+
() => ({
|
|
2046
|
+
reset: undoSelection,
|
|
2047
|
+
swap: (showOrigin?: boolean) => {
|
|
2048
|
+
onUserInteraction();
|
|
2049
|
+
if (showOrigin === undefined) {
|
|
2050
|
+
setCompareMode(v => !v);
|
|
2051
|
+
} else {
|
|
2052
|
+
setCompareMode(showOrigin);
|
|
2053
|
+
}
|
|
2054
|
+
},
|
|
2055
|
+
save: async options => {
|
|
2056
|
+
const livePainted = paintedRegionsRef.current && paintedRegionsRef.current.size > 0
|
|
2057
|
+
? paintedRegionsRef.current
|
|
2058
|
+
: paintedRegions;
|
|
2059
|
+
|
|
2060
|
+
const fp = paintedRegionsFingerprint(livePainted);
|
|
2061
|
+
const cached = lastExportCacheRef.current;
|
|
2062
|
+
if (cached?.fingerprint === fp && cached.result) {
|
|
2063
|
+
return resolveExportResultForDestDir(cached.result, options?.destDir);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
try {
|
|
2067
|
+
return await runExportPipeline(livePainted, options?.destDir);
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
console.error('[VIZ-SAVE] SDK save() composite threw:', e);
|
|
2070
|
+
throw e;
|
|
2071
|
+
}
|
|
2072
|
+
},
|
|
2073
|
+
getLastExport: () => lastExportCacheRef.current?.result ?? null,
|
|
2074
|
+
session: () => ({
|
|
2075
|
+
version: 1 as const,
|
|
2076
|
+
originUrl: originUrlRef.current,
|
|
2077
|
+
maskUrl: maskUrlRef.current,
|
|
2078
|
+
painted: buildPaintedRecords(),
|
|
2079
|
+
paintHistory: [...paintHistory],
|
|
2080
|
+
currentColor: customPaintColor ?? undefined,
|
|
2081
|
+
currentColorConfigJson: customPaintConfigJsonRef.current,
|
|
2082
|
+
savedAt: Date.now(),
|
|
2083
|
+
}),
|
|
2084
|
+
loadSession: restoreSession,
|
|
2085
|
+
setPaintColor: (color, configJson) => {
|
|
2086
|
+
setCustomPaintColor(color);
|
|
2087
|
+
customPaintConfigJsonRef.current = configJson;
|
|
2088
|
+
setActiveBrushIndex(null);
|
|
2089
|
+
},
|
|
2090
|
+
setMaskConfig: config => {
|
|
2091
|
+
setMaskSegmentRuntimeConfig({ maskConfig: config });
|
|
2092
|
+
runtimeRef.current = getMaskSegmentRuntimeConfig();
|
|
2093
|
+
if (originImgPath && maskImgPath) {
|
|
2094
|
+
lastSegmentKeyRef.current = '';
|
|
2095
|
+
void segmentAndPrepareLayers(originImgPath, maskImgPath);
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
clearAllPaint,
|
|
2099
|
+
undoSelection,
|
|
2100
|
+
resegment: clearCacheAndResegment,
|
|
2101
|
+
getRegions: () => [...regionsRef.current],
|
|
2102
|
+
getPaintedRegions: () => buildPaintedRecords(),
|
|
2103
|
+
}),
|
|
2104
|
+
[
|
|
2105
|
+
undoSelection,
|
|
2106
|
+
onUserInteraction,
|
|
2107
|
+
paintedRegions,
|
|
2108
|
+
paintHistory,
|
|
2109
|
+
customPaintColor,
|
|
2110
|
+
buildPaintedRecords,
|
|
2111
|
+
restoreSession,
|
|
2112
|
+
clearAllPaint,
|
|
2113
|
+
clearCacheAndResegment,
|
|
2114
|
+
runExportPipeline,
|
|
2115
|
+
],
|
|
2116
|
+
);
|
|
2117
|
+
|
|
2118
|
+
const [regionOutlinePaths, setRegionOutlinePaths] = useState<
|
|
2119
|
+
Map<number, SkPath>
|
|
2120
|
+
>(new Map());
|
|
2121
|
+
|
|
2122
|
+
useEffect(() => {
|
|
2123
|
+
if (!segmentsReady || !containRect || regionPalette.length === 0) {
|
|
2124
|
+
if (!segmentsReady) {
|
|
2125
|
+
setRegionOutlinePaths(new Map());
|
|
2126
|
+
setMaskPathsReady(false);
|
|
2127
|
+
maskPathsContainRectRef.current = null;
|
|
2128
|
+
}
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const maskData = regionMaskDataRef.current;
|
|
2133
|
+
if (!maskData) {
|
|
2134
|
+
setRegionOutlinePaths(new Map());
|
|
2135
|
+
setMaskPathsReady(false);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (
|
|
2140
|
+
maskPathsContainRectRef.current &&
|
|
2141
|
+
rectsEqual(maskPathsContainRectRef.current, containRect)
|
|
2142
|
+
) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
const pathStart = __DEV__ ? performance.now() : 0;
|
|
2147
|
+
const pathMaskData = downsampleMaskDataForPaths(
|
|
2148
|
+
maskData,
|
|
2149
|
+
getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide,
|
|
2150
|
+
);
|
|
2151
|
+
const outlines = buildAllRegionOutlinePaths(
|
|
2152
|
+
regionPalette,
|
|
2153
|
+
pathMaskData,
|
|
2154
|
+
containRect,
|
|
2155
|
+
);
|
|
2156
|
+
|
|
2157
|
+
maskPathsContainRectRef.current = containRect;
|
|
2158
|
+
setRegionOutlinePaths(outlines);
|
|
2159
|
+
setMaskPathsReady(true);
|
|
2160
|
+
maskPathsReadyRef.current = true;
|
|
2161
|
+
emitMaskPathsReadyIfReady();
|
|
2162
|
+
}, [segmentsReady, containRect, regionPalette, emitMaskPathsReadyIfReady]);
|
|
2163
|
+
|
|
2164
|
+
const heldOutlinePath = useMemo(() => {
|
|
2165
|
+
if (
|
|
2166
|
+
heldRegionId == null ||
|
|
2167
|
+
heldRegionAnchor == null ||
|
|
2168
|
+
!containRect ||
|
|
2169
|
+
regionPalette.length === 0
|
|
2170
|
+
) {
|
|
2171
|
+
return null;
|
|
2172
|
+
}
|
|
2173
|
+
const maskData = regionMaskDataRef.current;
|
|
2174
|
+
if (!maskData) {
|
|
2175
|
+
return null;
|
|
2176
|
+
}
|
|
2177
|
+
const pathMaskData = downsampleMaskDataForPaths(
|
|
2178
|
+
maskData,
|
|
2179
|
+
getMaskSegmentRuntimeConfig().pipeline.maskPathMaxLongSide,
|
|
2180
|
+
);
|
|
2181
|
+
return buildRegionOutlinePathForRegion(
|
|
2182
|
+
heldRegionId,
|
|
2183
|
+
regionPalette,
|
|
2184
|
+
pathMaskData,
|
|
2185
|
+
containRect,
|
|
2186
|
+
heldRegionAnchor,
|
|
2187
|
+
);
|
|
2188
|
+
}, [heldRegionId, heldRegionAnchor, containRect, regionPalette]);
|
|
2189
|
+
|
|
2190
|
+
const renderImageLayer = (image: SkImage | null, opacity = 1) => {
|
|
2191
|
+
if (!image || !containRect) return null;
|
|
2192
|
+
return (
|
|
2193
|
+
<SkiaImage
|
|
2194
|
+
image={image}
|
|
2195
|
+
x={containRect.x}
|
|
2196
|
+
y={containRect.y}
|
|
2197
|
+
width={containRect.w}
|
|
2198
|
+
height={containRect.h}
|
|
2199
|
+
opacity={opacity}
|
|
2200
|
+
/>
|
|
2201
|
+
);
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
const renderRegionMaskOverlay = (regionId: number, keyPrefix: string) => {
|
|
2205
|
+
const path = regionOutlinePaths.get(regionId);
|
|
2206
|
+
if (!path || !containRect) return null;
|
|
2207
|
+
return (
|
|
2208
|
+
<>
|
|
2209
|
+
<Path
|
|
2210
|
+
key={`${keyPrefix}-fill-${regionId}`}
|
|
2211
|
+
path={path}
|
|
2212
|
+
color={paintRuntime.regionOverlayFill}
|
|
2213
|
+
style="fill"
|
|
2214
|
+
opacity={0.05}
|
|
2215
|
+
/>
|
|
2216
|
+
<Path
|
|
2217
|
+
key={`${keyPrefix}-stroke-${regionId}`}
|
|
2218
|
+
path={path}
|
|
2219
|
+
color={paintRuntime.regionOverlayFill}
|
|
2220
|
+
style="stroke"
|
|
2221
|
+
strokeWidth={3}
|
|
2222
|
+
strokeJoin="round"
|
|
2223
|
+
antiAlias
|
|
2224
|
+
>
|
|
2225
|
+
<DashPathEffect intervals={[8, 6]} />
|
|
2226
|
+
</Path>
|
|
2227
|
+
</>
|
|
2228
|
+
);
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
// ── Zoom transform for the Skia Group ─────────────────────────────────
|
|
2232
|
+
// Note: single-finger pan/drag is disabled. Zoom is always centered around the
|
|
2233
|
+
// viewport center (no additional panOffset translation). The panOffset state
|
|
2234
|
+
// is kept (and forced to 0 during pinch) for compatibility with screenToCanvasCoords
|
|
2235
|
+
// inverse mapping used by tap/paint logic, and for resetZoom.
|
|
2236
|
+
const zoomTransform = useMemo(() => {
|
|
2237
|
+
if (zoomScale <= 1) return undefined;
|
|
2238
|
+
return [
|
|
2239
|
+
{ translateX: 0, translateY: 0 }, // panning disabled; content stays centered when zoomed
|
|
2240
|
+
{ translateX: canvasW / 2, translateY: canvasH / 2 },
|
|
2241
|
+
{ scale: zoomScale },
|
|
2242
|
+
{ translateX: -canvasW / 2, translateY: -canvasH / 2 },
|
|
2243
|
+
];
|
|
2244
|
+
}, [zoomScale, canvasW, canvasH]);
|
|
2245
|
+
|
|
2246
|
+
const renderDraw = () => {
|
|
2247
|
+
const displayImg = originSkImg ?? lowFreqSkImg;
|
|
2248
|
+
if (!displayImg || !containRect) {
|
|
2249
|
+
return null;
|
|
2250
|
+
}
|
|
2251
|
+
const showOverlay = !compareMode && segmentsReady;
|
|
2252
|
+
const shaderReady =
|
|
2253
|
+
paintColorMapSkImg &&
|
|
2254
|
+
lowFreqSkImg &&
|
|
2255
|
+
highFreqSkImg &&
|
|
2256
|
+
paintResourcesReady;
|
|
2257
|
+
const useShader =
|
|
2258
|
+
!compareMode && paintedRegions.size > 0 && shaderReady;
|
|
2259
|
+
const shaderOrigin = originSkImg ?? lowFreqSkImg;
|
|
2260
|
+
|
|
2261
|
+
// Background color for areas of the viewport that become visible around the
|
|
2262
|
+
// zoomed (centered) photo content. Using a light gray that matches common
|
|
2263
|
+
// preview container backgrounds prevents "mosaic-like colored blocks"
|
|
2264
|
+
// (shader leakage or edge sampling artifacts) from appearing outside the
|
|
2265
|
+
// photo rect when the content is scaled up.
|
|
2266
|
+
const previewBg = '#F0F1F3';
|
|
2267
|
+
|
|
2268
|
+
return (
|
|
2269
|
+
<>
|
|
2270
|
+
{/* Fixed background drawn first (not affected by zoom).
|
|
2271
|
+
Any area of the viewport not covered by the scaled photo content
|
|
2272
|
+
will show this clean color. */}
|
|
2273
|
+
<Rect x={0} y={0} width={canvasW} height={canvasH} color={previewBg} />
|
|
2274
|
+
|
|
2275
|
+
{/* Fixed clip window (in viewport coordinates). The photo content is
|
|
2276
|
+
drawn inside this clip after applying the centered zoom transform.
|
|
2277
|
+
The clip keeps drawing contained within the logical viewport and
|
|
2278
|
+
prevents shader content from leaking outside during zoom. */}
|
|
2279
|
+
<Group clip={Skia.XYWHRect(0, 0, canvasW, canvasH)}>
|
|
2280
|
+
<Group transform={zoomTransform}>
|
|
2281
|
+
{useShader && shaderOrigin ? (
|
|
2282
|
+
<PaintShaderLayer
|
|
2283
|
+
originImage={shaderOrigin}
|
|
2284
|
+
paintColorMap={paintColorMapSkImg}
|
|
2285
|
+
lowFreqImage={lowFreqSkImg}
|
|
2286
|
+
highFreqImage={highFreqSkImg}
|
|
2287
|
+
x={containRect.x}
|
|
2288
|
+
y={containRect.y}
|
|
2289
|
+
width={containRect.w}
|
|
2290
|
+
height={containRect.h}
|
|
2291
|
+
showOrigin={false}
|
|
2292
|
+
/>
|
|
2293
|
+
) : (
|
|
2294
|
+
renderImageLayer(displayImg)
|
|
2295
|
+
)}
|
|
2296
|
+
|
|
2297
|
+
{showOverlay &&
|
|
2298
|
+
initFlashRegionId != null &&
|
|
2299
|
+
renderRegionMaskOverlay(initFlashRegionId, 'init-overlay')}
|
|
2300
|
+
|
|
2301
|
+
{showOverlay &&
|
|
2302
|
+
heldRegionId != null &&
|
|
2303
|
+
!paintedRegions.has(heldRegionId) &&
|
|
2304
|
+
renderRegionMaskOverlay(heldRegionId, 'hold-overlay')}
|
|
2305
|
+
</Group>
|
|
2306
|
+
</Group>
|
|
2307
|
+
</>
|
|
2308
|
+
);
|
|
2309
|
+
};
|
|
2310
|
+
|
|
2311
|
+
// Full-bleed (0,0 to work size) composition for the high-res export snapshot canvas.
|
|
2312
|
+
// No UI overlays (dashes, held, flash). When painted + shader ready we use the exact
|
|
2313
|
+
// same PaintShaderLayer the user sees in the editor, so makeImageSnapshot() gives
|
|
2314
|
+
const renderFullResPainted = () => {
|
|
2315
|
+
const sz = exportCanvasSize;
|
|
2316
|
+
if (!sz) return null;
|
|
2317
|
+
const ew = sz.w;
|
|
2318
|
+
const eh = sz.h;
|
|
2319
|
+
|
|
2320
|
+
const shaderReady =
|
|
2321
|
+
paintColorMapSkImg &&
|
|
2322
|
+
lowFreqSkImg &&
|
|
2323
|
+
highFreqSkImg &&
|
|
2324
|
+
paintResourcesReady;
|
|
2325
|
+
const useShader = paintedRegions.size > 0 && shaderReady;
|
|
2326
|
+
const shaderOrigin = originSkImg ?? lowFreqSkImg;
|
|
2327
|
+
|
|
2328
|
+
if (useShader && shaderOrigin) {
|
|
2329
|
+
return (
|
|
2330
|
+
<PaintShaderLayer
|
|
2331
|
+
originImage={shaderOrigin}
|
|
2332
|
+
paintColorMap={paintColorMapSkImg}
|
|
2333
|
+
lowFreqImage={lowFreqSkImg}
|
|
2334
|
+
highFreqImage={highFreqSkImg}
|
|
2335
|
+
x={0}
|
|
2336
|
+
y={0}
|
|
2337
|
+
width={ew}
|
|
2338
|
+
height={eh}
|
|
2339
|
+
showOrigin={false}
|
|
2340
|
+
/>
|
|
2341
|
+
);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// No paints yet or shader not ready — export the (scaled) origin as-is.
|
|
2345
|
+
const displayImg = originSkImg ?? lowFreqSkImg;
|
|
2346
|
+
if (displayImg) {
|
|
2347
|
+
return (
|
|
2348
|
+
<SkiaImage
|
|
2349
|
+
image={displayImg}
|
|
2350
|
+
x={0}
|
|
2351
|
+
y={0}
|
|
2352
|
+
width={ew}
|
|
2353
|
+
height={eh}
|
|
2354
|
+
/>
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
return null;
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
// ── Gesture: tap (single-finger paint / highlight / brush_required) ────
|
|
2361
|
+
const tapGesture = useMemo(
|
|
2362
|
+
() => {
|
|
2363
|
+
const onBeginJS = (x: number, y: number) => {
|
|
2364
|
+
// Immediate hold highlight — fires on touch-down regardless of brush state.
|
|
2365
|
+
// When a brush is active, this lets the user preview which region they're
|
|
2366
|
+
// about to paint before lifting their finger.
|
|
2367
|
+
onUserInteraction();
|
|
2368
|
+
const coords = screenToCanvasCoords(
|
|
2369
|
+
x, y,
|
|
2370
|
+
canvasWRef.current, canvasHRef.current,
|
|
2371
|
+
zoomScaleRef.current, panOffsetRef.current,
|
|
2372
|
+
);
|
|
2373
|
+
const regionId = findRegionAtPointRef.current(coords.x, coords.y, true);
|
|
2374
|
+
if (regionId == null || !imageSizeRef2.current) {
|
|
2375
|
+
setHeldRegionId(null);
|
|
2376
|
+
setHeldRegionAnchor(null);
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (paintedRegionsRef.current && paintedRegionsRef.current.has(regionId)) {
|
|
2380
|
+
setHeldRegionId(null);
|
|
2381
|
+
setHeldRegionAnchor(null);
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const norm = canvasToNormalized(
|
|
2385
|
+
coords.x, coords.y,
|
|
2386
|
+
canvasWRef.current, canvasHRef.current,
|
|
2387
|
+
imageSizeRef2.current.w, imageSizeRef2.current.h,
|
|
2388
|
+
);
|
|
2389
|
+
setHeldRegionId(regionId);
|
|
2390
|
+
setHeldRegionAnchor(norm);
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
const onEndJS = (x: number, y: number, success: boolean) => {
|
|
2394
|
+
if (!success) return;
|
|
2395
|
+
const coords = screenToCanvasCoords(
|
|
2396
|
+
x, y,
|
|
2397
|
+
canvasWRef.current, canvasHRef.current,
|
|
2398
|
+
zoomScaleRef.current, panOffsetRef.current,
|
|
2399
|
+
);
|
|
2400
|
+
if (hasActiveBrushRef.current) {
|
|
2401
|
+
onCanvasTapRef.current(coords.x, coords.y);
|
|
2402
|
+
} else {
|
|
2403
|
+
// Brush_required
|
|
2404
|
+
onUserInteraction();
|
|
2405
|
+
if (disabledRef.current || !segmentsReadyRef2.current || !imageSizeRef2.current) return;
|
|
2406
|
+
const regionId = findRegionAtPointRef.current(coords.x, coords.y);
|
|
2407
|
+
if (regionId == null) return;
|
|
2408
|
+
const region = regionsRef.current.find(r => r.id === regionId);
|
|
2409
|
+
onPaintCallbackRef.current?.({
|
|
2410
|
+
kind: 'brush_required',
|
|
2411
|
+
hint: '请先选择笔刷颜色',
|
|
2412
|
+
regionId,
|
|
2413
|
+
regionName: region?.name ?? String(regionId),
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
|
|
2418
|
+
const onFinalizeJS = (x: number, y: number) => {
|
|
2419
|
+
// Cleanup hold highlight (replaces onCanvasPressOut)
|
|
2420
|
+
if (hasActiveBrushRef.current) {
|
|
2421
|
+
setHeldRegionId(null);
|
|
2422
|
+
setHeldRegionAnchor(null);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const coords = screenToCanvasCoords(
|
|
2426
|
+
x, y,
|
|
2427
|
+
canvasWRef.current, canvasHRef.current,
|
|
2428
|
+
zoomScaleRef.current, panOffsetRef.current,
|
|
2429
|
+
);
|
|
2430
|
+
onCanvasTapRef.current(coords.x, coords.y);
|
|
2431
|
+
setHeldRegionId(null);
|
|
2432
|
+
setHeldRegionAnchor(null);
|
|
2433
|
+
};
|
|
2434
|
+
|
|
2435
|
+
return Gesture.Tap()
|
|
2436
|
+
.onBegin((e) => {
|
|
2437
|
+
'worklet';
|
|
2438
|
+
runOnJS(onBeginJS)(e.x, e.y);
|
|
2439
|
+
})
|
|
2440
|
+
.onEnd((e, success) => {
|
|
2441
|
+
'worklet';
|
|
2442
|
+
runOnJS(onEndJS)(e.x, e.y, success);
|
|
2443
|
+
})
|
|
2444
|
+
.onFinalize((e) => {
|
|
2445
|
+
'worklet';
|
|
2446
|
+
runOnJS(onFinalizeJS)(e.x, e.y);
|
|
2447
|
+
});
|
|
2448
|
+
},
|
|
2449
|
+
[onUserInteraction],
|
|
2450
|
+
);
|
|
2451
|
+
|
|
2452
|
+
// ── Gesture: pinch-zoom (two-finger scale; max 5×) ─────────────────────
|
|
2453
|
+
const pinchGesture = useMemo(
|
|
2454
|
+
() => {
|
|
2455
|
+
const onStartJS = () => {
|
|
2456
|
+
zoomBaseRef.current = zoomScaleRef.current;
|
|
2457
|
+
};
|
|
2458
|
+
const onUpdateJS = (scale: number) => {
|
|
2459
|
+
const newScale = Math.max(1, Math.min(zoomBaseRef.current * scale, 5));
|
|
2460
|
+
setZoomScale(newScale);
|
|
2461
|
+
zoomScaleRef.current = newScale;
|
|
2462
|
+
// Lock pan offset to zero: only two-finger pinch zoom is supported.
|
|
2463
|
+
// Single-finger drag/pan after zoom is intentionally disabled per product decision.
|
|
2464
|
+
// Zoom is always centered; no additional translate from panOffset is applied in practice.
|
|
2465
|
+
setPanOffset({ x: 0, y: 0 });
|
|
2466
|
+
panOffsetRef.current = { x: 0, y: 0 };
|
|
2467
|
+
};
|
|
2468
|
+
const onEndJS = () => {
|
|
2469
|
+
if (zoomScaleRef.current <= 1.01) {
|
|
2470
|
+
resetZoom();
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
return Gesture.Pinch()
|
|
2475
|
+
.onStart(() => {
|
|
2476
|
+
'worklet';
|
|
2477
|
+
runOnJS(onStartJS)();
|
|
2478
|
+
})
|
|
2479
|
+
.onUpdate((e) => {
|
|
2480
|
+
'worklet';
|
|
2481
|
+
runOnJS(onUpdateJS)(e.scale);
|
|
2482
|
+
})
|
|
2483
|
+
.onEnd(() => {
|
|
2484
|
+
'worklet';
|
|
2485
|
+
runOnJS(onEndJS)();
|
|
2486
|
+
});
|
|
2487
|
+
},
|
|
2488
|
+
[resetZoom],
|
|
2489
|
+
);
|
|
2490
|
+
|
|
2491
|
+
// ── Composed: Race ensures single-tap and pinch-zoom never conflict ─────────
|
|
2492
|
+
const composedGesture = useMemo(
|
|
2493
|
+
() => Gesture.Race(tapGesture, pinchGesture),
|
|
2494
|
+
[tapGesture, pinchGesture],
|
|
2495
|
+
);
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
return (
|
|
2499
|
+
<View
|
|
2500
|
+
style={[styles.container, style]}
|
|
2501
|
+
onLayout={(e) => {
|
|
2502
|
+
const { width, height } = e.nativeEvent.layout;
|
|
2503
|
+
setLayoutWidth(width);
|
|
2504
|
+
setLayoutHeight(height);
|
|
2505
|
+
}}
|
|
2506
|
+
>
|
|
2507
|
+
{/* Only render the internal ScrollView (and its control bars) when any of
|
|
2508
|
+
the auxiliary UI rows are requested. In the main VisualizationScreen
|
|
2509
|
+
(and scheme card live previews) all of showToolbar/showDebug/showStatus/showColorBar
|
|
2510
|
+
are false, so we omit this entirely.
|
|
2511
|
+
|
|
2512
|
+
This conditional is still important for gesture integrity:
|
|
2513
|
+
- An always-mounted vertical ScrollView can participate in the responder system
|
|
2514
|
+
and steal touches (vertical moves, or even interfere with two-finger pinch).
|
|
2515
|
+
- The GestureDetector (for tap/pinch) is a sibling after the ScrollView in the
|
|
2516
|
+
tree when the ScrollView is rendered, so it may not receive the intended gestures.
|
|
2517
|
+
- By omitting the ScrollView when unused, the canvas touch layer + GestureDetector
|
|
2518
|
+
become direct children and reliably receive single-finger taps and two-finger pinches. */}
|
|
2519
|
+
{(showToolbar || showDebugPickers || showStatusRow || showColorBar) ? (
|
|
2520
|
+
<ScrollView
|
|
2521
|
+
style={styles.scroll}
|
|
2522
|
+
showsVerticalScrollIndicator={false}
|
|
2523
|
+
contentContainerStyle={styles.scrollContent}
|
|
2524
|
+
keyboardShouldPersistTaps="always"
|
|
2525
|
+
>
|
|
2526
|
+
{showToolbar && (
|
|
2527
|
+
<View style={styles.toolbarRow}>
|
|
2528
|
+
<Button
|
|
2529
|
+
title={isRefreshing ? 're-segmenting…' : 'clear cache and re-segment'}
|
|
2530
|
+
onPress={clearCacheAndResegment}
|
|
2531
|
+
disabled={isRefreshing || !originImgPath || !maskImgPath}
|
|
2532
|
+
/>
|
|
2533
|
+
</View>
|
|
2534
|
+
)}
|
|
2535
|
+
|
|
2536
|
+
{showDebugPickers && (
|
|
2537
|
+
<View style={styles.btnRow}>
|
|
2538
|
+
<Button title="pick origin image" onPress={pickOriginImage} />
|
|
2539
|
+
<View style={styles.btnGap} />
|
|
2540
|
+
<Button title="pick mask image" onPress={pickMaskImage} />
|
|
2541
|
+
</View>
|
|
2542
|
+
)}
|
|
2543
|
+
|
|
2544
|
+
{showStatusRow && (
|
|
2545
|
+
<View style={styles.statusRow}>
|
|
2546
|
+
{segError ? (
|
|
2547
|
+
<Text style={styles.err}>❌ {segError}</Text>
|
|
2548
|
+
) : !segmentsReady ? (
|
|
2549
|
+
<Text style={styles.hint}>⏳ segmenting…</Text>
|
|
2550
|
+
) : layersLoading || !paintResourcesReady ? (
|
|
2551
|
+
<Text style={styles.hint}>
|
|
2552
|
+
✅ {regionCount} regions · Shader textures preparing…
|
|
2553
|
+
</Text>
|
|
2554
|
+
) : !(originSkImg ?? lowFreqSkImg) ? (
|
|
2555
|
+
<Text style={styles.hint}>⏳ image loading…</Text>
|
|
2556
|
+
) : (
|
|
2557
|
+
<Text style={styles.hint}>
|
|
2558
|
+
✅ {regionCount} regions · painted {paintedRegions.size} ·{' '}
|
|
2559
|
+
{hasActiveBrush
|
|
2560
|
+
? customPaintColor
|
|
2561
|
+
? 'custom paint color'
|
|
2562
|
+
: `paint color ${(activeBrushIndex ?? 0) + 1}`
|
|
2563
|
+
: 'please select the bottom paint color first'}
|
|
2564
|
+
{' · '}
|
|
2565
|
+
{compareMode ? 'compare original image' : 'paint mode'}
|
|
2566
|
+
</Text>
|
|
2567
|
+
)}
|
|
2568
|
+
</View>
|
|
2569
|
+
)}
|
|
2570
|
+
|
|
2571
|
+
{showColorBar && (
|
|
2572
|
+
<View style={styles.colorBar}>
|
|
2573
|
+
<Text style={styles.colorBarLabel}>
|
|
2574
|
+
paint color (tap to select, then tap canvas to paint)
|
|
2575
|
+
</Text>
|
|
2576
|
+
<View style={styles.colorSwatches}>
|
|
2577
|
+
{paintPalette.map((color, index) => {
|
|
2578
|
+
const isActive =
|
|
2579
|
+
activeBrushIndex === index && customPaintColor == null;
|
|
2580
|
+
const { b, g, r } = color;
|
|
2581
|
+
return (
|
|
2582
|
+
<TouchableOpacity
|
|
2583
|
+
key={index}
|
|
2584
|
+
style={[
|
|
2585
|
+
styles.colorSwatch,
|
|
2586
|
+
{ backgroundColor: bgrToCss(b, g, r) },
|
|
2587
|
+
isActive && styles.colorSwatchSelected,
|
|
2588
|
+
]}
|
|
2589
|
+
activeOpacity={0.8}
|
|
2590
|
+
disabled={!segmentsReady || disabled}
|
|
2591
|
+
onPress={() => selectBrushColor(index)}
|
|
2592
|
+
/>
|
|
2593
|
+
);
|
|
2594
|
+
})}
|
|
2595
|
+
{!segmentsReady && (
|
|
2596
|
+
<Text style={styles.colorBarEmpty}>loading…</Text>
|
|
2597
|
+
)}
|
|
2598
|
+
</View>
|
|
2599
|
+
{customPaintColor && (
|
|
2600
|
+
<Text style={styles.hint}>
|
|
2601
|
+
current custom paint color is set by ref.setPaintColor
|
|
2602
|
+
</Text>
|
|
2603
|
+
)}
|
|
2604
|
+
</View>
|
|
2605
|
+
)}
|
|
2606
|
+
</ScrollView>
|
|
2607
|
+
) : null}
|
|
2608
|
+
|
|
2609
|
+
<View
|
|
2610
|
+
style={[styles.canvasOuter, canvasStyle]}
|
|
2611
|
+
>
|
|
2612
|
+
<View
|
|
2613
|
+
style={[styles.canvasWrap, { width: canvasW, height: canvasH }]}
|
|
2614
|
+
>
|
|
2615
|
+
<GestureDetector gesture={composedGesture}>
|
|
2616
|
+
<View style={[styles.canvasTouchLayer, { width: canvasW, height: canvasH }]}>
|
|
2617
|
+
<Canvas style={{ width: canvasW, height: canvasH }} pointerEvents="none">
|
|
2618
|
+
{renderDraw()}
|
|
2619
|
+
</Canvas>
|
|
2620
|
+
</View>
|
|
2621
|
+
</GestureDetector>
|
|
2622
|
+
|
|
2623
|
+
{showOverlayButtons &&
|
|
2624
|
+
(renderUndoButton ? (
|
|
2625
|
+
renderUndoButton({
|
|
2626
|
+
onPress: undoSelection,
|
|
2627
|
+
disabled: paintHistory.length === 0,
|
|
2628
|
+
text: undoButtonText,
|
|
2629
|
+
})
|
|
2630
|
+
) : (
|
|
2631
|
+
<TouchableOpacity
|
|
2632
|
+
style={[styles.overlayBtn, styles.btnBottomLeft, undoButtonStyle]}
|
|
2633
|
+
activeOpacity={0.7}
|
|
2634
|
+
disabled={paintHistory.length === 0 || disabled}
|
|
2635
|
+
onPress={undoSelection}
|
|
2636
|
+
>
|
|
2637
|
+
<Text
|
|
2638
|
+
style={[
|
|
2639
|
+
styles.btnText,
|
|
2640
|
+
undoButtonTextStyle,
|
|
2641
|
+
{ opacity: paintHistory.length === 0 ? 0.4 : 1 },
|
|
2642
|
+
]}
|
|
2643
|
+
>
|
|
2644
|
+
{undoButtonText}
|
|
2645
|
+
</Text>
|
|
2646
|
+
</TouchableOpacity>
|
|
2647
|
+
))}
|
|
2648
|
+
|
|
2649
|
+
{showOverlayButtons &&
|
|
2650
|
+
(renderCompareButton ? (
|
|
2651
|
+
renderCompareButton({
|
|
2652
|
+
onPress: () => {
|
|
2653
|
+
onUserInteraction();
|
|
2654
|
+
setCompareMode(v => !v);
|
|
2655
|
+
},
|
|
2656
|
+
text: compareMode ? compareExitButtonText : compareButtonText,
|
|
2657
|
+
})
|
|
2658
|
+
) : (
|
|
2659
|
+
<TouchableOpacity
|
|
2660
|
+
style={[
|
|
2661
|
+
styles.overlayBtn,
|
|
2662
|
+
styles.btnBottomRight,
|
|
2663
|
+
compareButtonStyle,
|
|
2664
|
+
]}
|
|
2665
|
+
activeOpacity={0.7}
|
|
2666
|
+
disabled={disabled}
|
|
2667
|
+
onPress={() => {
|
|
2668
|
+
onUserInteraction();
|
|
2669
|
+
setCompareMode(v => !v);
|
|
2670
|
+
}}
|
|
2671
|
+
>
|
|
2672
|
+
<Text style={[styles.btnText, compareButtonTextStyle]}>
|
|
2673
|
+
{compareMode ? compareExitButtonText : compareButtonText}
|
|
2674
|
+
</Text>
|
|
2675
|
+
</TouchableOpacity>
|
|
2676
|
+
))}
|
|
2677
|
+
</View>
|
|
2678
|
+
</View>
|
|
2679
|
+
|
|
2680
|
+
{highResSnapshotEnabled && exportCanvasSize ? (
|
|
2681
|
+
<View
|
|
2682
|
+
style={{
|
|
2683
|
+
position: 'absolute',
|
|
2684
|
+
left: -exportCanvasSize.w - 200,
|
|
2685
|
+
top: 0,
|
|
2686
|
+
width: exportCanvasSize.w,
|
|
2687
|
+
height: exportCanvasSize.h,
|
|
2688
|
+
opacity: 0,
|
|
2689
|
+
overflow: 'hidden',
|
|
2690
|
+
}}
|
|
2691
|
+
pointerEvents="none"
|
|
2692
|
+
>
|
|
2693
|
+
<Canvas
|
|
2694
|
+
ref={highResExportCanvasRef}
|
|
2695
|
+
style={{ width: exportCanvasSize.w, height: exportCanvasSize.h }}
|
|
2696
|
+
pointerEvents="none"
|
|
2697
|
+
>
|
|
2698
|
+
<Group>
|
|
2699
|
+
{renderFullResPainted()}
|
|
2700
|
+
</Group>
|
|
2701
|
+
</Canvas>
|
|
2702
|
+
</View>
|
|
2703
|
+
) : null}
|
|
2704
|
+
</View>
|
|
2705
|
+
);
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
const styles = StyleSheet.create({
|
|
2709
|
+
container: {
|
|
2710
|
+
flex: 1,
|
|
2711
|
+
backgroundColor: '#fff',
|
|
2712
|
+
},
|
|
2713
|
+
scroll: {
|
|
2714
|
+
flex: 1,
|
|
2715
|
+
},
|
|
2716
|
+
scrollContent: {
|
|
2717
|
+
padding: 10,
|
|
2718
|
+
paddingBottom: 28,
|
|
2719
|
+
},
|
|
2720
|
+
toolbarRow: {
|
|
2721
|
+
marginBottom: 8,
|
|
2722
|
+
},
|
|
2723
|
+
btnRow: {
|
|
2724
|
+
flexDirection: 'row',
|
|
2725
|
+
marginBottom: 8,
|
|
2726
|
+
},
|
|
2727
|
+
btnGap: {
|
|
2728
|
+
width: 10,
|
|
2729
|
+
},
|
|
2730
|
+
statusRow: {
|
|
2731
|
+
marginBottom: 8,
|
|
2732
|
+
},
|
|
2733
|
+
hint: {
|
|
2734
|
+
color: '#333',
|
|
2735
|
+
fontSize: 13,
|
|
2736
|
+
},
|
|
2737
|
+
err: {
|
|
2738
|
+
color: '#c33',
|
|
2739
|
+
fontSize: 13,
|
|
2740
|
+
},
|
|
2741
|
+
canvasWrap: {
|
|
2742
|
+
position: 'relative',
|
|
2743
|
+
backgroundColor: '#f5f5f5',
|
|
2744
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
2745
|
+
borderColor: '#ddd',
|
|
2746
|
+
overflow: 'hidden',
|
|
2747
|
+
},
|
|
2748
|
+
canvasOuter: {
|
|
2749
|
+
alignItems: 'center',
|
|
2750
|
+
justifyContent: 'center',
|
|
2751
|
+
// When the internal control ScrollView is omitted (the normal case for
|
|
2752
|
+
// VisualizationScreen and non-interactive scheme previews), this makes the
|
|
2753
|
+
// canvas area fill the bounds provided by the host (via style + maxHeight)
|
|
2754
|
+
// and centers the fixed-size image rect. This also ensures the
|
|
2755
|
+
// GestureDetector sits in the right place to receive taps and two-finger pinches.
|
|
2756
|
+
flex: 1,
|
|
2757
|
+
},
|
|
2758
|
+
canvasTouchLayer: {
|
|
2759
|
+
flex: 1,
|
|
2760
|
+
},
|
|
2761
|
+
canvas: {
|
|
2762
|
+
flex: 1,
|
|
2763
|
+
},
|
|
2764
|
+
overlayBtn: {
|
|
2765
|
+
position: 'absolute',
|
|
2766
|
+
bottom: 10,
|
|
2767
|
+
paddingHorizontal: 14,
|
|
2768
|
+
paddingVertical: 7,
|
|
2769
|
+
backgroundColor: 'rgba(0, 0, 0, 0.62)',
|
|
2770
|
+
borderRadius: 6,
|
|
2771
|
+
},
|
|
2772
|
+
btnBottomLeft: {
|
|
2773
|
+
left: 10,
|
|
2774
|
+
},
|
|
2775
|
+
btnBottomRight: {
|
|
2776
|
+
right: 10,
|
|
2777
|
+
},
|
|
2778
|
+
btnText: {
|
|
2779
|
+
color: '#fff',
|
|
2780
|
+
fontSize: 13,
|
|
2781
|
+
fontWeight: '600',
|
|
2782
|
+
},
|
|
2783
|
+
colorBar: {
|
|
2784
|
+
marginTop: 12,
|
|
2785
|
+
paddingVertical: 10,
|
|
2786
|
+
paddingHorizontal: 4,
|
|
2787
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
2788
|
+
borderTopColor: '#e0e0e0',
|
|
2789
|
+
},
|
|
2790
|
+
colorBarLabel: {
|
|
2791
|
+
fontSize: 12,
|
|
2792
|
+
color: '#666',
|
|
2793
|
+
marginBottom: 10,
|
|
2794
|
+
},
|
|
2795
|
+
colorSwatches: {
|
|
2796
|
+
flexDirection: 'row',
|
|
2797
|
+
flexWrap: 'wrap',
|
|
2798
|
+
alignItems: 'center',
|
|
2799
|
+
gap: 12,
|
|
2800
|
+
},
|
|
2801
|
+
colorSwatch: {
|
|
2802
|
+
width: 44,
|
|
2803
|
+
height: 44,
|
|
2804
|
+
borderRadius: 22,
|
|
2805
|
+
borderWidth: 2,
|
|
2806
|
+
borderColor: 'rgba(0,0,0,0.12)',
|
|
2807
|
+
alignItems: 'center',
|
|
2808
|
+
justifyContent: 'center',
|
|
2809
|
+
},
|
|
2810
|
+
colorSwatchSelected: {
|
|
2811
|
+
borderColor: '#1e96ff',
|
|
2812
|
+
borderWidth: 3,
|
|
2813
|
+
transform: [{ scale: 1.08 }],
|
|
2814
|
+
},
|
|
2815
|
+
colorSwatchPainted: {
|
|
2816
|
+
borderColor: 'rgba(255,255,255,0.9)',
|
|
2817
|
+
},
|
|
2818
|
+
colorSwatchDot: {
|
|
2819
|
+
width: 8,
|
|
2820
|
+
height: 8,
|
|
2821
|
+
borderRadius: 4,
|
|
2822
|
+
backgroundColor: 'rgba(255,255,255,0.92)',
|
|
2823
|
+
borderWidth: 1,
|
|
2824
|
+
borderColor: 'rgba(0,0,0,0.25)',
|
|
2825
|
+
},
|
|
2826
|
+
colorBarEmpty: {
|
|
2827
|
+
fontSize: 13,
|
|
2828
|
+
color: '#999',
|
|
2829
|
+
},
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
export default MaskSegmentCanvas;
|