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.
Files changed (95) hide show
  1. package/README.md +904 -0
  2. package/dist/components/MaskSegmentCanvas.d.ts +6 -0
  3. package/dist/components/MaskSegmentCanvas.d.ts.map +1 -0
  4. package/dist/components/MaskSegmentCanvas.js +2012 -0
  5. package/dist/components/MaskSegmentCanvas.js.map +1 -0
  6. package/dist/components/MaskSegmentCanvas.types.d.ts +189 -0
  7. package/dist/components/MaskSegmentCanvas.types.d.ts.map +1 -0
  8. package/dist/components/MaskSegmentCanvas.types.js +2 -0
  9. package/dist/components/MaskSegmentCanvas.types.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +5 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/shaders/regionPaint.sksl.d.ts +3 -0
  15. package/dist/shaders/regionPaint.sksl.d.ts.map +1 -0
  16. package/dist/shaders/regionPaint.sksl.js +72 -0
  17. package/dist/shaders/regionPaint.sksl.js.map +1 -0
  18. package/dist/utils/compositePaintedImage.d.ts +44 -0
  19. package/dist/utils/compositePaintedImage.d.ts.map +1 -0
  20. package/dist/utils/compositePaintedImage.js +146 -0
  21. package/dist/utils/compositePaintedImage.js.map +1 -0
  22. package/dist/utils/exportUtils.d.ts +20 -0
  23. package/dist/utils/exportUtils.d.ts.map +1 -0
  24. package/dist/utils/exportUtils.js +32 -0
  25. package/dist/utils/exportUtils.js.map +1 -0
  26. package/dist/utils/freqLayerPrep.d.ts +23 -0
  27. package/dist/utils/freqLayerPrep.d.ts.map +1 -0
  28. package/dist/utils/freqLayerPrep.js +168 -0
  29. package/dist/utils/freqLayerPrep.js.map +1 -0
  30. package/dist/utils/maskSegmentRuntime.d.ts +43 -0
  31. package/dist/utils/maskSegmentRuntime.d.ts.map +1 -0
  32. package/dist/utils/maskSegmentRuntime.js +181 -0
  33. package/dist/utils/maskSegmentRuntime.js.map +1 -0
  34. package/dist/utils/maskSegmentation.d.ts +133 -0
  35. package/dist/utils/maskSegmentation.d.ts.map +1 -0
  36. package/dist/utils/maskSegmentation.js +1600 -0
  37. package/dist/utils/maskSegmentation.js.map +1 -0
  38. package/dist/utils/maskSemanticPalette.d.ts +31 -0
  39. package/dist/utils/maskSemanticPalette.d.ts.map +1 -0
  40. package/dist/utils/maskSemanticPalette.js +125 -0
  41. package/dist/utils/maskSemanticPalette.js.map +1 -0
  42. package/dist/utils/opencvAdapter.d.ts +116 -0
  43. package/dist/utils/opencvAdapter.d.ts.map +1 -0
  44. package/dist/utils/opencvAdapter.js +353 -0
  45. package/dist/utils/opencvAdapter.js.map +1 -0
  46. package/dist/utils/paintColorMapTexture.d.ts +5 -0
  47. package/dist/utils/paintColorMapTexture.d.ts.map +1 -0
  48. package/dist/utils/paintColorMapTexture.js +203 -0
  49. package/dist/utils/paintColorMapTexture.js.map +1 -0
  50. package/dist/utils/paintShaderRuntime.d.ts +40 -0
  51. package/dist/utils/paintShaderRuntime.d.ts.map +1 -0
  52. package/dist/utils/paintShaderRuntime.js +76 -0
  53. package/dist/utils/paintShaderRuntime.js.map +1 -0
  54. package/dist/utils/pickMapTexture.d.ts +4 -0
  55. package/dist/utils/pickMapTexture.d.ts.map +1 -0
  56. package/dist/utils/pickMapTexture.js +24 -0
  57. package/dist/utils/pickMapTexture.js.map +1 -0
  58. package/dist/utils/pngImage.d.ts +49 -0
  59. package/dist/utils/pngImage.d.ts.map +1 -0
  60. package/dist/utils/pngImage.js +438 -0
  61. package/dist/utils/pngImage.js.map +1 -0
  62. package/dist/utils/resolveAssetPath.d.ts +3 -0
  63. package/dist/utils/resolveAssetPath.d.ts.map +1 -0
  64. package/dist/utils/resolveAssetPath.js +56 -0
  65. package/dist/utils/resolveAssetPath.js.map +1 -0
  66. package/dist/utils/resolveImageUrl.d.ts +3 -0
  67. package/dist/utils/resolveImageUrl.d.ts.map +1 -0
  68. package/dist/utils/resolveImageUrl.js +51 -0
  69. package/dist/utils/resolveImageUrl.js.map +1 -0
  70. package/dist/utils/skiaImage.d.ts +4 -0
  71. package/dist/utils/skiaImage.d.ts.map +1 -0
  72. package/dist/utils/skiaImage.js +12 -0
  73. package/dist/utils/skiaImage.js.map +1 -0
  74. package/package.json +100 -0
  75. package/patches/react-native-fast-opencv+0.4.8.patch +122 -0
  76. package/src/components/MaskSegmentCanvas.tsx +2832 -0
  77. package/src/components/MaskSegmentCanvas.types.ts +216 -0
  78. package/src/globals.d.ts +19 -0
  79. package/src/index.ts +45 -0
  80. package/src/shaders/regionPaint.sksl.ts +71 -0
  81. package/src/upng-js.d.ts +33 -0
  82. package/src/utils/compositePaintedImage.ts +201 -0
  83. package/src/utils/exportUtils.ts +40 -0
  84. package/src/utils/freqLayerPrep.ts +267 -0
  85. package/src/utils/maskSegmentRuntime.ts +257 -0
  86. package/src/utils/maskSegmentation.ts +2294 -0
  87. package/src/utils/maskSemanticPalette.ts +187 -0
  88. package/src/utils/opencvAdapter.ts +539 -0
  89. package/src/utils/paintColorMapTexture.ts +239 -0
  90. package/src/utils/paintShaderRuntime.tsx +150 -0
  91. package/src/utils/pickMapTexture.ts +37 -0
  92. package/src/utils/pngImage.ts +591 -0
  93. package/src/utils/resolveAssetPath.ts +64 -0
  94. package/src/utils/resolveImageUrl.ts +63 -0
  95. package/src/utils/skiaImage.ts +25 -0
@@ -0,0 +1,591 @@
1
+ import RNFS from 'react-native-fs';
2
+ import UPNG from 'upng-js';
3
+ import {
4
+ OpenCV,
5
+ ObjectType,
6
+ DataTypes,
7
+ ColorConversionCodes,
8
+ type Mat,
9
+ } from 'react-native-fast-opencv';
10
+
11
+ export const PNG_EXT = '.png';
12
+ /** PNG 压缩级别 0 = 无损 */
13
+ export const PNG_COMPRESSION = 0;
14
+
15
+ export function normalizePath(path: string): string {
16
+ return path.startsWith('file://') ? path.slice(7) : path;
17
+ }
18
+
19
+ /** Skia useImage 需要 URI;OpenCV / RNFS 使用裸路径 */
20
+ export function toSkiaUri(path: string | null | undefined): string | null {
21
+ if (!path) {
22
+ return null;
23
+ }
24
+ if (
25
+ path.startsWith('http://') ||
26
+ path.startsWith('https://') ||
27
+ path.startsWith('data:') ||
28
+ path.startsWith('file://')
29
+ ) {
30
+ return path;
31
+ }
32
+ const normalized = normalizePath(path);
33
+ return `file://${normalized}`;
34
+ }
35
+
36
+ export function toPngFileName(name: string): string {
37
+ const base = name.replace(/\.(jpe?g|webp|gif|bmp|heic|heif)$/i, '');
38
+ return base.toLowerCase().endsWith(PNG_EXT) ? base : `${base}${PNG_EXT}`;
39
+ }
40
+
41
+ export function isPngPath(path: string): boolean {
42
+ return normalizePath(path).toLowerCase().endsWith(PNG_EXT);
43
+ }
44
+
45
+ function hashString(value: string): string {
46
+ let hash = 5381;
47
+ for (let i = 0; i < value.length; i++) {
48
+ hash = Math.imul(hash, 33) ^ value.charCodeAt(i);
49
+ }
50
+ return (hash >>> 0).toString(16);
51
+ }
52
+
53
+ function hashPath(path: string): string {
54
+ return hashString(path);
55
+ }
56
+
57
+ /** 按文件元数据生成指纹,避免整文件读入(原 base64 全量哈希在 1.5MB 图上极慢) */
58
+ export async function fileContentFingerprint(path: string): Promise<string> {
59
+ const normalized = normalizePath(path);
60
+ const stat = await RNFS.stat(normalized);
61
+ return hashString(`${stat.size}:${stat.mtime ?? stat.ctime}`);
62
+ }
63
+
64
+ function versionedCachePath(cacheFileName: string, fingerprint: string): string {
65
+ const baseName = toPngFileName(cacheFileName).replace(/\.png$/i, '');
66
+ return `${RNFS.CachesDirectoryPath}/${baseName}_${fingerprint}${PNG_EXT}`;
67
+ }
68
+
69
+ async function cleanupStaleVersionedCache(
70
+ cacheFileName: string,
71
+ keepDest: string,
72
+ ): Promise<void> {
73
+ const baseName = toPngFileName(cacheFileName).replace(/\.png$/i, '');
74
+ const legacyDest = `${RNFS.CachesDirectoryPath}/${toPngFileName(cacheFileName)}`;
75
+ const prefix = `${baseName}_`;
76
+
77
+ if (legacyDest !== keepDest && (await RNFS.exists(legacyDest))) {
78
+ await RNFS.unlink(legacyDest);
79
+ }
80
+
81
+ const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
82
+ for (const file of files) {
83
+ if (
84
+ file.isFile() &&
85
+ file.name.startsWith(prefix) &&
86
+ file.name.endsWith(PNG_EXT) &&
87
+ file.path !== keepDest
88
+ ) {
89
+ await RNFS.unlink(file.path);
90
+ }
91
+ }
92
+ }
93
+
94
+ /** 任意图片路径 → 缓存目录下的 PNG 文件路径(已是 PNG 则复制,否则解码转存) */
95
+ export async function ensurePngFile(
96
+ sourcePath: string,
97
+ cacheFileName: string,
98
+ ): Promise<string> {
99
+ const src = normalizePath(sourcePath);
100
+ const fingerprint = await fileContentFingerprint(src);
101
+ const dest = versionedCachePath(cacheFileName, fingerprint);
102
+
103
+ if (!(await RNFS.exists(src))) {
104
+ throw new Error(`Image does not exist: ${src}`);
105
+ }
106
+
107
+ if (src === dest) {
108
+ return dest;
109
+ }
110
+
111
+ if (await RNFS.exists(dest)) {
112
+ return dest;
113
+ }
114
+
115
+ if (isPngPath(src)) {
116
+ await RNFS.copyFile(src, dest);
117
+ await cleanupStaleVersionedCache(cacheFileName, dest);
118
+ return dest;
119
+ }
120
+
121
+ const base64 = await RNFS.readFile(src, 'base64');
122
+ const mat = OpenCV.base64ToMat(base64);
123
+ OpenCV.saveMatToFile(mat, dest, 'png', PNG_COMPRESSION);
124
+ OpenCV.releaseBuffers([mat.id]);
125
+ await cleanupStaleVersionedCache(cacheFileName, dest);
126
+ return dest;
127
+ }
128
+
129
+ /** 根据源路径生成稳定 PNG 缓存名 */
130
+ export function pngCacheName(sourcePath: string, prefix: string): string {
131
+ return `${prefix}_${hashPath(normalizePath(sourcePath))}${PNG_EXT}`;
132
+ }
133
+
134
+ const DERIVED_CACHE_PREFIXES = [
135
+ 'seg_lowfreq_',
136
+ 'seg_highfreq_',
137
+ 'imread_',
138
+ 'img_',
139
+ 'tmp_',
140
+ ];
141
+
142
+ /** 清理分割/OpenCV 派生缓存,保留原图与掩码源文件 */
143
+ export async function clearDerivedImageCache(): Promise<number> {
144
+ const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
145
+ let removed = 0;
146
+
147
+ for (const file of files) {
148
+ if (!file.isFile() || !file.name.endsWith(PNG_EXT)) {
149
+ continue;
150
+ }
151
+ if (!DERIVED_CACHE_PREFIXES.some(prefix => file.name.startsWith(prefix))) {
152
+ continue;
153
+ }
154
+ await RNFS.unlink(file.path);
155
+ removed += 1;
156
+ }
157
+
158
+ return removed;
159
+ }
160
+
161
+ const CV_MAT_DEPTH_MASK = 7;
162
+
163
+ function matDepth(type: number): number {
164
+ return type & CV_MAT_DEPTH_MASK;
165
+ }
166
+
167
+ function matChannelsFromType(type: number): number {
168
+ return (type >> 3) + 1;
169
+ }
170
+
171
+ type PngHeader = {
172
+ width: number;
173
+ height: number;
174
+ bitDepth: number;
175
+ colorType: number; // 0=Gray 2=RGB 3=Palette 4=GrayAlpha 6=RGBA
176
+ };
177
+
178
+ function pngColorTypeToChannels(colorType: number): number {
179
+ switch (colorType) {
180
+ case 0:
181
+ return 1;
182
+ case 2:
183
+ case 3:
184
+ return 3;
185
+ case 4:
186
+ return 2;
187
+ case 6:
188
+ return 4;
189
+ default:
190
+ return 3;
191
+ }
192
+ }
193
+
194
+ /** 从 base64 解析 PNG IHDR(不依赖 OpenCV),用于 16-bit Mat toJSValue 崩溃时的降级通路 */
195
+ export function readPngHeaderFromBase64(base64: string): PngHeader {
196
+ // PNG 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26 字节
197
+ // base64 每 3 字节 → 4 字符,取前 40 字符覆盖 ~30 字节
198
+ const headerPart = base64.slice(0, 40);
199
+ const binary = atob(headerPart);
200
+
201
+ function readUint32BE(offset: number): number {
202
+ return (
203
+ ((binary.charCodeAt(offset) << 24) |
204
+ (binary.charCodeAt(offset + 1) << 16) |
205
+ (binary.charCodeAt(offset + 2) << 8) |
206
+ binary.charCodeAt(offset + 3)) >>>
207
+ 0
208
+ );
209
+ }
210
+
211
+ // 校验 PNG 签名
212
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
213
+ for (let i = 0; i < 8; i++) {
214
+ if (binary.charCodeAt(i) !== sig[i]) {
215
+ throw new Error('Invalid PNG file (signature mismatch)');
216
+ }
217
+ }
218
+
219
+ const width = readUint32BE(16);
220
+ const height = readUint32BE(20);
221
+ const bitDepth = binary.charCodeAt(24);
222
+ const colorType = binary.charCodeAt(25);
223
+
224
+ if (width === 0 || height === 0) {
225
+ throw new Error(`Invalid PNG size: ${width}x${height}`);
226
+ }
227
+
228
+ return { width, height, bitDepth, colorType };
229
+ }
230
+
231
+ /** 16-bit / float Mat → 8-bit(fast-opencv 在 patch 前 toJSValue 会截断高位)。
232
+ * Semantic mask PNGs use 16-bit RGB where the label (0-255) is stored as value×257;
233
+ * use 1/257 scaling for 3+ channel 16-bit cases so the resulting 8-bit channels contain
234
+ * the original semantic label values (consistent with the native ensure8U patch).
235
+ *
236
+ * pngHeader 可选:当 toJSValue 因 16-bit Mat 崩溃时,用 PNG 文件头信息做降级转换,
237
+ * 绕过原生 toJSValue 调用。
238
+ */
239
+ export function ensureMat8U(
240
+ srcMat: Mat,
241
+ pngHeader?: PngHeader,
242
+ ): {
243
+ mat: Mat;
244
+ extraReleaseIds: string[];
245
+ } {
246
+ let info: { rows: number; cols: number; type: number } | undefined;
247
+ try {
248
+ info = OpenCV.toJSValue(srcMat) as unknown as {
249
+ rows: number;
250
+ cols: number;
251
+ type: number;
252
+ };
253
+ } catch (toJsError) {
254
+ if (!pngHeader) {
255
+ throw new Error(
256
+ `OpenCV toJSValue failed (mat id=${srcMat?.id ?? 'unknown'}): ${(toJsError as Error).message}`,
257
+ );
258
+ }
259
+
260
+ // 降级通路: 16-bit PNG 的 Mat 无法通过 toJSValue 读取元数据,
261
+ // 直接使用 PNG 文件头中的宽高和位深信息做 convertTo
262
+ const { width: cols, height: rows, bitDepth, colorType } = pngHeader;
263
+ const ch = pngColorTypeToChannels(colorType);
264
+
265
+ if (bitDepth <= 8) {
266
+ // 8-bit 或更低,不需要 convertTo,直接返回
267
+ return { mat: srcMat, extraReleaseIds: [] };
268
+ }
269
+
270
+ const outType =
271
+ ch === 1
272
+ ? DataTypes.CV_8UC1
273
+ : ch === 4
274
+ ? DataTypes.CV_8UC4
275
+ : DataTypes.CV_8UC3;
276
+ const dstMat = OpenCV.createObject(
277
+ ObjectType.Mat,
278
+ rows,
279
+ cols,
280
+ outType,
281
+ ) as Mat;
282
+
283
+ // 16-bit RGB 语义掩码: 标签值 = 原始值 / 257
284
+ const alpha = ch >= 3 ? 1 / 257 : 255 / 65535;
285
+ OpenCV.invoke('convertTo', srcMat, dstMat, outType, alpha, 0);
286
+ return { mat: dstMat, extraReleaseIds: [dstMat.id] };
287
+ }
288
+
289
+ if (matDepth(info.type) === DataTypes.CV_8U) {
290
+ return { mat: srcMat, extraReleaseIds: [] };
291
+ }
292
+
293
+ const rows = info.rows;
294
+ const cols = info.cols;
295
+ const ch = matChannelsFromType(info.type);
296
+ const outType =
297
+ ch === 1
298
+ ? DataTypes.CV_8UC1
299
+ : ch === 4
300
+ ? DataTypes.CV_8UC4
301
+ : ch === 2
302
+ ? DataTypes.CV_8UC2
303
+ : DataTypes.CV_8UC3;
304
+ const dstMat = OpenCV.createObject(
305
+ ObjectType.Mat,
306
+ rows,
307
+ cols,
308
+ outType,
309
+ ) as Mat;
310
+ const depth = matDepth(info.type);
311
+ let alpha = 1;
312
+ if (depth === DataTypes.CV_16U || depth === DataTypes.CV_16S) {
313
+ // Special case for semantic mask 16-bit "RGB label" encoding (value × 257)
314
+ alpha = ch >= 3 ? 1 / 257 : 255 / 65535;
315
+ } else if (depth === DataTypes.CV_32F || depth === DataTypes.CV_64F) {
316
+ alpha = 255;
317
+ }
318
+ OpenCV.invoke('convertTo', srcMat, dstMat, outType, alpha, 0);
319
+ return { mat: dstMat, extraReleaseIds: [dstMat.id] };
320
+ }
321
+
322
+ /**
323
+ * JS fallback PNG decoder (OpenCV base64ToMat may throw HostFunction for 16-bit PNGs).
324
+ * 16-bit RGB semantic masks use value×257 encoding → UPNG.toRGBA8 high byte = original 8-bit label.
325
+ */
326
+ function decodePngToBgrJS(base64: string): {
327
+ buffer: Uint8Array;
328
+ cols: number;
329
+ rows: number;
330
+ } {
331
+ const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
332
+ if (!atobFn) {
333
+ throw new Error('JS PNG fallback: atob unavailable');
334
+ }
335
+ const binary = atobFn(base64);
336
+ const bytes = new Uint8Array(binary.length);
337
+ for (let i = 0; i < binary.length; i++) {
338
+ bytes[i] = binary.charCodeAt(i);
339
+ }
340
+
341
+ const decoded = UPNG.decode(bytes);
342
+ const rgbaFrames = UPNG.toRGBA8(decoded);
343
+ const rgbaBuf = new Uint8Array(rgbaFrames[0] as ArrayBuffer);
344
+ const w = decoded.width;
345
+ const h = decoded.height;
346
+ const pixelCount = w * h;
347
+
348
+ // RGBA → BGR (drop alpha)
349
+ const bgr = new Uint8Array(pixelCount * 3);
350
+ for (let i = 0; i < pixelCount; i++) {
351
+ const si = i * 4;
352
+ const di = i * 3;
353
+ bgr[di] = rgbaBuf[si + 2] as number; // B ← R
354
+ bgr[di + 1] = rgbaBuf[si + 1] as number; // G
355
+ bgr[di + 2] = rgbaBuf[si] as number; // R ← B
356
+ }
357
+
358
+ return { buffer: bgr, cols: w, rows: h };
359
+ }
360
+
361
+ /** Only check PNG magic bytes (89 50 4E 47) — agnostic to bit depth.
362
+ * OpenCV bridge may throw HostFunction for both 8-bit and 16-bit PNGs.
363
+ * Fall back to UPNG pure-JS decode for any valid PNG (supports all variants). */
364
+ function isPngByBase64Magic(base64: string): boolean {
365
+ if (!base64 || base64.length < 8) {
366
+ return false;
367
+ }
368
+ try {
369
+ const atobFn = (globalThis as { atob?: (data: string) => string }).atob;
370
+ if (!atobFn) {
371
+ return false;
372
+ }
373
+ const binary = atobFn(base64.slice(0, 12)); // 8 bytes → 12 base64 chars
374
+ return (
375
+ binary.charCodeAt(0) === 0x89 &&
376
+ binary.charCodeAt(1) === 0x50 &&
377
+ binary.charCodeAt(2) === 0x4e &&
378
+ binary.charCodeAt(3) === 0x47
379
+ );
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ /** base64 PNG → 连续 BGR 缓冲(OpenCV 原生解码,跳过 JS atob + upng) */
386
+ function decodeBase64PngToBgr(base64: string): {
387
+ buffer: Uint8Array;
388
+ cols: number;
389
+ rows: number;
390
+ } {
391
+ let srcMat: Mat;
392
+ try {
393
+ srcMat = OpenCV.base64ToMat(base64);
394
+ } catch (e) {
395
+ // OpenCV bridge may throw HostFunction for any PNG (8-bit or 16-bit).
396
+ // Fall back to pure JS decode whenever the file header is PNG.
397
+ if (isPngByBase64Magic(base64)) {
398
+ try {
399
+ return decodePngToBgrJS(base64);
400
+ } catch (jsError) {
401
+ throw new Error(
402
+ `JS PNG fallback also failed (base64 length ${base64?.length ?? 0}): ${(jsError as Error).message}`,
403
+ );
404
+ }
405
+ }
406
+ throw new Error(
407
+ `OpenCV base64ToMat failed (base64 length ${base64?.length ?? 0}): ${(e as Error).message}`,
408
+ );
409
+ }
410
+ if (!srcMat || typeof srcMat.id !== 'string' || !srcMat.id) {
411
+ throw new Error(
412
+ `OpenCV base64ToMat returned invalid Mat (base64 length ${base64?.length ?? 0})`,
413
+ );
414
+ }
415
+ const releaseIds: string[] = [srcMat.id];
416
+ try {
417
+ // 先解析 PNG 文件头(宽高、位深),供 ensureMat8U 在 16-bit toJSValue 崩溃时降级使用
418
+ let pngHeader: PngHeader | undefined;
419
+ try {
420
+ pngHeader = readPngHeaderFromBase64(base64);
421
+ } catch {
422
+ // 非 PNG 或解析失败,不传 header,确保 retain 原有行为
423
+ }
424
+
425
+ let workMat: Mat;
426
+ let extraReleaseIds: string[];
427
+ try {
428
+ const result = ensureMat8U(srcMat, pngHeader);
429
+ workMat = result.mat;
430
+ extraReleaseIds = result.extraReleaseIds;
431
+ } catch (e) {
432
+ throw new Error(`ensureMat8U failed: ${(e as Error).message}`);
433
+ }
434
+ releaseIds.push(...extraReleaseIds);
435
+ const info = OpenCV.toJSValue(workMat);
436
+ const cols = info.cols;
437
+ const rows = info.rows;
438
+ let bgrMat: Mat = workMat;
439
+
440
+ if (info.type === DataTypes.CV_8UC4) {
441
+ bgrMat = OpenCV.createObject(
442
+ ObjectType.Mat,
443
+ rows,
444
+ cols,
445
+ DataTypes.CV_8UC3,
446
+ ) as Mat;
447
+ releaseIds.push(bgrMat.id);
448
+ OpenCV.invoke(
449
+ 'cvtColor',
450
+ workMat,
451
+ bgrMat,
452
+ ColorConversionCodes.COLOR_BGRA2BGR,
453
+ );
454
+ } else if (info.type === DataTypes.CV_8UC1) {
455
+ bgrMat = OpenCV.createObject(
456
+ ObjectType.Mat,
457
+ rows,
458
+ cols,
459
+ DataTypes.CV_8UC3,
460
+ ) as Mat;
461
+ releaseIds.push(bgrMat.id);
462
+ OpenCV.invoke(
463
+ 'cvtColor',
464
+ workMat,
465
+ bgrMat,
466
+ ColorConversionCodes.COLOR_GRAY2BGR,
467
+ );
468
+ }
469
+
470
+ const continuous = OpenCV.invoke('clone', bgrMat) as Mat;
471
+ releaseIds.push(continuous.id);
472
+ const { buffer, cols: outCols, rows: outRows } = OpenCV.matToBuffer(
473
+ continuous,
474
+ 'uint8',
475
+ );
476
+ return {
477
+ buffer: new Uint8Array(buffer),
478
+ cols: outCols,
479
+ rows: outRows,
480
+ };
481
+ } finally {
482
+ OpenCV.releaseBuffers(releaseIds);
483
+ }
484
+ }
485
+
486
+ export type PngBgrBuffer = {
487
+ buffer: Uint8Array;
488
+ cols: number;
489
+ rows: number;
490
+ };
491
+
492
+ const pngBgrCache = new Map<string, PngBgrBuffer>();
493
+ const prewarmInFlight = new Map<string, Promise<PngBgrBuffer>>();
494
+
495
+ export function prewarmPngBgrCache(paths: string[]): void {
496
+ for (const path of paths) {
497
+ const filePath = normalizePath(path);
498
+ void readPngBgrBuffer(filePath).catch(() => {});
499
+ }
500
+ }
501
+
502
+ export async function prewarmPngBgrCacheAsync(
503
+ paths: string[],
504
+ ): Promise<void> {
505
+ await Promise.all(paths.map(path => readPngBgrBuffer(normalizePath(path))));
506
+ }
507
+
508
+ export async function pngContentCacheKey(path: string): Promise<string> {
509
+ const stat = await RNFS.stat(normalizePath(path));
510
+ return `${stat.size}:${stat.mtime ?? stat.ctime}`;
511
+ }
512
+
513
+ export async function readPngBgrBuffer(path: string): Promise<PngBgrBuffer> {
514
+ const filePath = normalizePath(path);
515
+ const cacheKey = await pngContentCacheKey(filePath);
516
+ const cached = pngBgrCache.get(cacheKey);
517
+ if (cached) {
518
+ return cached;
519
+ }
520
+
521
+ const inflight = prewarmInFlight.get(cacheKey);
522
+ if (inflight) {
523
+ return inflight;
524
+ }
525
+
526
+ const loadPromise = (async (): Promise<PngBgrBuffer> => {
527
+ const exists = await RNFS.exists(filePath);
528
+ if (!exists) {
529
+ throw new Error(`Image file does not exist: ${filePath}`);
530
+ }
531
+ const stat = await RNFS.stat(filePath);
532
+ if (stat.size === 0) {
533
+ throw new Error(`Image file is empty (0 bytes): ${filePath}`);
534
+ }
535
+ const base64 = await RNFS.readFile(filePath, 'base64');
536
+ if (!base64 || base64.length === 0) {
537
+ throw new Error(`Read base64 is empty: ${filePath}`);
538
+ }
539
+ let decoded;
540
+ try {
541
+ decoded = decodeBase64PngToBgr(base64);
542
+ } catch (decodeError) {
543
+ throw new Error(
544
+ `PNG decode failed: ${filePath} (${stat.size} bytes) - ${
545
+ (decodeError as Error).message
546
+ }`,
547
+ );
548
+ }
549
+ const entry: PngBgrBuffer = {
550
+ buffer: decoded.buffer,
551
+ cols: decoded.cols,
552
+ rows: decoded.rows,
553
+ };
554
+ pngBgrCache.set(cacheKey, entry);
555
+ return entry;
556
+ })();
557
+
558
+ prewarmInFlight.set(cacheKey, loadPromise);
559
+ try {
560
+ const result = await loadPromise;
561
+ return result;
562
+ } finally {
563
+ prewarmInFlight.delete(cacheKey);
564
+ }
565
+ }
566
+
567
+ export function resizeBgrBuffer(
568
+ buffer: Uint8Array,
569
+ srcCols: number,
570
+ srcRows: number,
571
+ dstCols: number,
572
+ dstRows: number,
573
+ ): Uint8Array {
574
+ if (srcCols === dstCols && srcRows === dstRows) {
575
+ return buffer;
576
+ }
577
+
578
+ const out = new Uint8Array(dstCols * dstRows * 3);
579
+ for (let y = 0; y < dstRows; y++) {
580
+ const sy = Math.min(srcRows - 1, Math.floor((y * srcRows) / dstRows));
581
+ for (let x = 0; x < dstCols; x++) {
582
+ const sx = Math.min(srcCols - 1, Math.floor((x * srcCols) / dstCols));
583
+ const si = (sy * srcCols + sx) * 3;
584
+ const di = (y * dstCols + x) * 3;
585
+ out[di] = buffer[si];
586
+ out[di + 1] = buffer[si + 1];
587
+ out[di + 2] = buffer[si + 2];
588
+ }
589
+ }
590
+ return out;
591
+ }
@@ -0,0 +1,64 @@
1
+ import { Image, Platform } from 'react-native';
2
+ import RNFS from 'react-native-fs';
3
+ import { ensurePngFile, toPngFileName } from './pngImage';
4
+
5
+ /** 将 require() 资源解析为 PNG 本地路径(OpenCV / RNFS 可读) */
6
+ export async function resolveAssetPath(
7
+ assetModule: number,
8
+ cacheFileName: string,
9
+ ): Promise<string> {
10
+ const pngCacheName = toPngFileName(cacheFileName);
11
+ const source = Image.resolveAssetSource(assetModule);
12
+ if (!source?.uri) {
13
+ throw new Error('Cannot resolve image resource');
14
+ }
15
+
16
+ const { uri } = source;
17
+
18
+ if (uri.startsWith('file://')) {
19
+ return ensurePngFile(uri, pngCacheName);
20
+ }
21
+
22
+ if (Platform.OS === 'ios' && uri.startsWith('/')) {
23
+ return ensurePngFile(uri, pngCacheName);
24
+ }
25
+
26
+ if (Platform.OS === 'ios' && uri.startsWith('/')) {
27
+ return ensurePngFile(uri, pngCacheName);
28
+ }
29
+
30
+ if (uri.startsWith('http://') || uri.startsWith('https://')) {
31
+ const tmpDest = `${RNFS.CachesDirectoryPath}/tmp_${Date.now()}_${pngCacheName}`;
32
+ const { statusCode } = await RNFS.downloadFile({
33
+ fromUrl: uri,
34
+ toFile: tmpDest,
35
+ }).promise;
36
+ if (statusCode !== 200) {
37
+ throw new Error(`Download resource failed: ${pngCacheName}`);
38
+ }
39
+ try {
40
+ return await ensurePngFile(tmpDest, pngCacheName);
41
+ } finally {
42
+ if (await RNFS.exists(tmpDest)) {
43
+ await RNFS.unlink(tmpDest);
44
+ }
45
+ }
46
+ }
47
+
48
+ if (Platform.OS === 'android') {
49
+ const assetPath = uri
50
+ .replace('asset:/', '')
51
+ .replace('file:///android_asset/', '');
52
+ const tmpDest = `${RNFS.CachesDirectoryPath}/tmp_${Date.now()}_${pngCacheName}`;
53
+ await RNFS.copyFileAssets(assetPath, tmpDest);
54
+ try {
55
+ return await ensurePngFile(tmpDest, pngCacheName);
56
+ } finally {
57
+ if (await RNFS.exists(tmpDest)) {
58
+ await RNFS.unlink(tmpDest);
59
+ }
60
+ }
61
+ }
62
+
63
+ return ensurePngFile(uri, pngCacheName);
64
+ }
@@ -0,0 +1,63 @@
1
+ import { Platform } from 'react-native';
2
+ import RNFS from 'react-native-fs';
3
+ import { ensurePngFile, isPngPath, normalizePath, toPngFileName } from './pngImage';
4
+
5
+ function hashUrl(url: string): string {
6
+ let hash = 0;
7
+ for (let i = 0; i < url.length; i++) {
8
+ hash = (hash * 31 + url.charCodeAt(i)) | 0;
9
+ }
10
+ return Math.abs(hash).toString(36);
11
+ }
12
+
13
+ /** 将本地路径或远程 URL 解析为 OpenCV / RNFS 可读的 PNG 本地路径 */
14
+ export async function resolveImageUrl(
15
+ source: string,
16
+ cacheFileName?: string,
17
+ ): Promise<string> {
18
+ const trimmed = source.trim();
19
+ if (!trimmed) {
20
+ throw new Error('Image URL is empty');
21
+ }
22
+
23
+ const pngCacheName = toPngFileName(
24
+ cacheFileName ?? `img_${hashUrl(trimmed)}.png`,
25
+ );
26
+
27
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
28
+ const tmpDest = `${RNFS.CachesDirectoryPath}/tmp_${Date.now()}_${pngCacheName}`;
29
+ const { statusCode } = await RNFS.downloadFile({
30
+ fromUrl: trimmed,
31
+ toFile: tmpDest,
32
+ }).promise;
33
+ if (statusCode !== 200) {
34
+ throw new Error(`Download image failed: ${trimmed}`);
35
+ }
36
+ try {
37
+ return await ensurePngFile(tmpDest, pngCacheName);
38
+ } finally {
39
+ if (await RNFS.exists(tmpDest)) {
40
+ await RNFS.unlink(tmpDest);
41
+ }
42
+ }
43
+ }
44
+
45
+ const normalized = normalizePath(trimmed);
46
+ if (await RNFS.exists(normalized) && isPngPath(normalized)) {
47
+ return normalized;
48
+ }
49
+
50
+ if (normalized.startsWith('file://')) {
51
+ return ensurePngFile(normalized, pngCacheName);
52
+ }
53
+
54
+ if (Platform.OS === 'ios' && normalized.startsWith('/')) {
55
+ return ensurePngFile(normalized, pngCacheName);
56
+ }
57
+
58
+ if (await RNFS.exists(normalized)) {
59
+ return ensurePngFile(normalized, pngCacheName);
60
+ }
61
+
62
+ return ensurePngFile(trimmed, pngCacheName);
63
+ }