438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
import RNFS from 'react-native-fs';
|
||
import UPNG from 'upng-js';
|
||
import { OpenCV, ObjectType, DataTypes, ColorConversionCodes, } from 'react-native-fast-opencv';
|
||
export const PNG_EXT = '.png';
|
||
/** PNG 压缩级别 0 = 无损 */
|
||
export const PNG_COMPRESSION = 0;
|
||
export function normalizePath(path) {
|
||
return path.startsWith('file://') ? path.slice(7) : path;
|
||
}
|
||
/** Skia useImage 需要 URI;OpenCV / RNFS 使用裸路径 */
|
||
export function toSkiaUri(path) {
|
||
if (!path) {
|
||
return null;
|
||
}
|
||
if (path.startsWith('http://') ||
|
||
path.startsWith('https://') ||
|
||
path.startsWith('data:') ||
|
||
path.startsWith('file://')) {
|
||
return path;
|
||
}
|
||
const normalized = normalizePath(path);
|
||
return `file://${normalized}`;
|
||
}
|
||
export function toPngFileName(name) {
|
||
const base = name.replace(/\.(jpe?g|webp|gif|bmp|heic|heif)$/i, '');
|
||
return base.toLowerCase().endsWith(PNG_EXT) ? base : `${base}${PNG_EXT}`;
|
||
}
|
||
export function isPngPath(path) {
|
||
return normalizePath(path).toLowerCase().endsWith(PNG_EXT);
|
||
}
|
||
function hashString(value) {
|
||
let hash = 5381;
|
||
for (let i = 0; i < value.length; i++) {
|
||
hash = Math.imul(hash, 33) ^ value.charCodeAt(i);
|
||
}
|
||
return (hash >>> 0).toString(16);
|
||
}
|
||
function hashPath(path) {
|
||
return hashString(path);
|
||
}
|
||
/** 按文件元数据生成指纹,避免整文件读入(原 base64 全量哈希在 1.5MB 图上极慢) */
|
||
export async function fileContentFingerprint(path) {
|
||
const normalized = normalizePath(path);
|
||
const stat = await RNFS.stat(normalized);
|
||
return hashString(`${stat.size}:${stat.mtime ?? stat.ctime}`);
|
||
}
|
||
function versionedCachePath(cacheFileName, fingerprint) {
|
||
const baseName = toPngFileName(cacheFileName).replace(/\.png$/i, '');
|
||
return `${RNFS.CachesDirectoryPath}/${baseName}_${fingerprint}${PNG_EXT}`;
|
||
}
|
||
async function cleanupStaleVersionedCache(cacheFileName, keepDest) {
|
||
const baseName = toPngFileName(cacheFileName).replace(/\.png$/i, '');
|
||
const legacyDest = `${RNFS.CachesDirectoryPath}/${toPngFileName(cacheFileName)}`;
|
||
const prefix = `${baseName}_`;
|
||
if (legacyDest !== keepDest && (await RNFS.exists(legacyDest))) {
|
||
await RNFS.unlink(legacyDest);
|
||
}
|
||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||
for (const file of files) {
|
||
if (file.isFile() &&
|
||
file.name.startsWith(prefix) &&
|
||
file.name.endsWith(PNG_EXT) &&
|
||
file.path !== keepDest) {
|
||
await RNFS.unlink(file.path);
|
||
}
|
||
}
|
||
}
|
||
/** 任意图片路径 → 缓存目录下的 PNG 文件路径(已是 PNG 则复制,否则解码转存) */
|
||
export async function ensurePngFile(sourcePath, cacheFileName) {
|
||
const src = normalizePath(sourcePath);
|
||
const fingerprint = await fileContentFingerprint(src);
|
||
const dest = versionedCachePath(cacheFileName, fingerprint);
|
||
if (!(await RNFS.exists(src))) {
|
||
throw new Error(`Image does not exist: ${src}`);
|
||
}
|
||
if (src === dest) {
|
||
return dest;
|
||
}
|
||
if (await RNFS.exists(dest)) {
|
||
return dest;
|
||
}
|
||
if (isPngPath(src)) {
|
||
await RNFS.copyFile(src, dest);
|
||
await cleanupStaleVersionedCache(cacheFileName, dest);
|
||
return dest;
|
||
}
|
||
const base64 = await RNFS.readFile(src, 'base64');
|
||
const mat = OpenCV.base64ToMat(base64);
|
||
OpenCV.saveMatToFile(mat, dest, 'png', PNG_COMPRESSION);
|
||
OpenCV.releaseBuffers([mat.id]);
|
||
await cleanupStaleVersionedCache(cacheFileName, dest);
|
||
return dest;
|
||
}
|
||
/** 根据源路径生成稳定 PNG 缓存名 */
|
||
export function pngCacheName(sourcePath, prefix) {
|
||
return `${prefix}_${hashPath(normalizePath(sourcePath))}${PNG_EXT}`;
|
||
}
|
||
const DERIVED_CACHE_PREFIXES = [
|
||
'seg_lowfreq_',
|
||
'seg_highfreq_',
|
||
'imread_',
|
||
'img_',
|
||
'tmp_',
|
||
];
|
||
/** 清理分割/OpenCV 派生缓存,保留原图与掩码源文件 */
|
||
export async function clearDerivedImageCache() {
|
||
const files = await RNFS.readDir(RNFS.CachesDirectoryPath);
|
||
let removed = 0;
|
||
for (const file of files) {
|
||
if (!file.isFile() || !file.name.endsWith(PNG_EXT)) {
|
||
continue;
|
||
}
|
||
if (!DERIVED_CACHE_PREFIXES.some(prefix => file.name.startsWith(prefix))) {
|
||
continue;
|
||
}
|
||
await RNFS.unlink(file.path);
|
||
removed += 1;
|
||
}
|
||
return removed;
|
||
}
|
||
const CV_MAT_DEPTH_MASK = 7;
|
||
function matDepth(type) {
|
||
return type & CV_MAT_DEPTH_MASK;
|
||
}
|
||
function matChannelsFromType(type) {
|
||
return (type >> 3) + 1;
|
||
}
|
||
function pngColorTypeToChannels(colorType) {
|
||
switch (colorType) {
|
||
case 0:
|
||
return 1;
|
||
case 2:
|
||
case 3:
|
||
return 3;
|
||
case 4:
|
||
return 2;
|
||
case 6:
|
||
return 4;
|
||
default:
|
||
return 3;
|
||
}
|
||
}
|
||
/** 从 base64 解析 PNG IHDR(不依赖 OpenCV),用于 16-bit Mat toJSValue 崩溃时的降级通路 */
|
||
export function readPngHeaderFromBase64(base64) {
|
||
// PNG 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26 字节
|
||
// base64 每 3 字节 → 4 字符,取前 40 字符覆盖 ~30 字节
|
||
const headerPart = base64.slice(0, 40);
|
||
const binary = atob(headerPart);
|
||
function readUint32BE(offset) {
|
||
return (((binary.charCodeAt(offset) << 24) |
|
||
(binary.charCodeAt(offset + 1) << 16) |
|
||
(binary.charCodeAt(offset + 2) << 8) |
|
||
binary.charCodeAt(offset + 3)) >>>
|
||
0);
|
||
}
|
||
// 校验 PNG 签名
|
||
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
||
for (let i = 0; i < 8; i++) {
|
||
if (binary.charCodeAt(i) !== sig[i]) {
|
||
throw new Error('Invalid PNG file (signature mismatch)');
|
||
}
|
||
}
|
||
const width = readUint32BE(16);
|
||
const height = readUint32BE(20);
|
||
const bitDepth = binary.charCodeAt(24);
|
||
const colorType = binary.charCodeAt(25);
|
||
if (width === 0 || height === 0) {
|
||
throw new Error(`Invalid PNG size: ${width}x${height}`);
|
||
}
|
||
return { width, height, bitDepth, colorType };
|
||
}
|
||
/** 16-bit / float Mat → 8-bit(fast-opencv 在 patch 前 toJSValue 会截断高位)。
|
||
* Semantic mask PNGs use 16-bit RGB where the label (0-255) is stored as value×257;
|
||
* use 1/257 scaling for 3+ channel 16-bit cases so the resulting 8-bit channels contain
|
||
* the original semantic label values (consistent with the native ensure8U patch).
|
||
*
|
||
* pngHeader 可选:当 toJSValue 因 16-bit Mat 崩溃时,用 PNG 文件头信息做降级转换,
|
||
* 绕过原生 toJSValue 调用。
|
||
*/
|
||
export function ensureMat8U(srcMat, pngHeader) {
|
||
let info;
|
||
try {
|
||
info = OpenCV.toJSValue(srcMat);
|
||
}
|
||
catch (toJsError) {
|
||
if (!pngHeader) {
|
||
throw new Error(`OpenCV toJSValue failed (mat id=${srcMat?.id ?? 'unknown'}): ${toJsError.message}`);
|
||
}
|
||
// 降级通路: 16-bit PNG 的 Mat 无法通过 toJSValue 读取元数据,
|
||
// 直接使用 PNG 文件头中的宽高和位深信息做 convertTo
|
||
const { width: cols, height: rows, bitDepth, colorType } = pngHeader;
|
||
const ch = pngColorTypeToChannels(colorType);
|
||
if (bitDepth <= 8) {
|
||
// 8-bit 或更低,不需要 convertTo,直接返回
|
||
return { mat: srcMat, extraReleaseIds: [] };
|
||
}
|
||
const outType = ch === 1
|
||
? DataTypes.CV_8UC1
|
||
: ch === 4
|
||
? DataTypes.CV_8UC4
|
||
: DataTypes.CV_8UC3;
|
||
const dstMat = OpenCV.createObject(ObjectType.Mat, rows, cols, outType);
|
||
// 16-bit RGB 语义掩码: 标签值 = 原始值 / 257
|
||
const alpha = ch >= 3 ? 1 / 257 : 255 / 65535;
|
||
OpenCV.invoke('convertTo', srcMat, dstMat, outType, alpha, 0);
|
||
return { mat: dstMat, extraReleaseIds: [dstMat.id] };
|
||
}
|
||
if (matDepth(info.type) === DataTypes.CV_8U) {
|
||
return { mat: srcMat, extraReleaseIds: [] };
|
||
}
|
||
const rows = info.rows;
|
||
const cols = info.cols;
|
||
const ch = matChannelsFromType(info.type);
|
||
const outType = ch === 1
|
||
? DataTypes.CV_8UC1
|
||
: ch === 4
|
||
? DataTypes.CV_8UC4
|
||
: ch === 2
|
||
? DataTypes.CV_8UC2
|
||
: DataTypes.CV_8UC3;
|
||
const dstMat = OpenCV.createObject(ObjectType.Mat, rows, cols, outType);
|
||
const depth = matDepth(info.type);
|
||
let alpha = 1;
|
||
if (depth === DataTypes.CV_16U || depth === DataTypes.CV_16S) {
|
||
// Special case for semantic mask 16-bit "RGB label" encoding (value × 257)
|
||
alpha = ch >= 3 ? 1 / 257 : 255 / 65535;
|
||
}
|
||
else if (depth === DataTypes.CV_32F || depth === DataTypes.CV_64F) {
|
||
alpha = 255;
|
||
}
|
||
OpenCV.invoke('convertTo', srcMat, dstMat, outType, alpha, 0);
|
||
return { mat: dstMat, extraReleaseIds: [dstMat.id] };
|
||
}
|
||
/**
|
||
* JS fallback PNG decoder (OpenCV base64ToMat may throw HostFunction for 16-bit PNGs).
|
||
* 16-bit RGB semantic masks use value×257 encoding → UPNG.toRGBA8 high byte = original 8-bit label.
|
||
*/
|
||
function decodePngToBgrJS(base64) {
|
||
const atobFn = globalThis.atob;
|
||
if (!atobFn) {
|
||
throw new Error('JS PNG fallback: atob unavailable');
|
||
}
|
||
const binary = atobFn(base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) {
|
||
bytes[i] = binary.charCodeAt(i);
|
||
}
|
||
const decoded = UPNG.decode(bytes);
|
||
const rgbaFrames = UPNG.toRGBA8(decoded);
|
||
const rgbaBuf = new Uint8Array(rgbaFrames[0]);
|
||
const w = decoded.width;
|
||
const h = decoded.height;
|
||
const pixelCount = w * h;
|
||
// RGBA → BGR (drop alpha)
|
||
const bgr = new Uint8Array(pixelCount * 3);
|
||
for (let i = 0; i < pixelCount; i++) {
|
||
const si = i * 4;
|
||
const di = i * 3;
|
||
bgr[di] = rgbaBuf[si + 2]; // B ← R
|
||
bgr[di + 1] = rgbaBuf[si + 1]; // G
|
||
bgr[di + 2] = rgbaBuf[si]; // R ← B
|
||
}
|
||
return { buffer: bgr, cols: w, rows: h };
|
||
}
|
||
/** Only check PNG magic bytes (89 50 4E 47) — agnostic to bit depth.
|
||
* OpenCV bridge may throw HostFunction for both 8-bit and 16-bit PNGs.
|
||
* Fall back to UPNG pure-JS decode for any valid PNG (supports all variants). */
|
||
function isPngByBase64Magic(base64) {
|
||
if (!base64 || base64.length < 8) {
|
||
return false;
|
||
}
|
||
try {
|
||
const atobFn = globalThis.atob;
|
||
if (!atobFn) {
|
||
return false;
|
||
}
|
||
const binary = atobFn(base64.slice(0, 12)); // 8 bytes → 12 base64 chars
|
||
return (binary.charCodeAt(0) === 0x89 &&
|
||
binary.charCodeAt(1) === 0x50 &&
|
||
binary.charCodeAt(2) === 0x4e &&
|
||
binary.charCodeAt(3) === 0x47);
|
||
}
|
||
catch {
|
||
return false;
|
||
}
|
||
}
|
||
/** base64 PNG → 连续 BGR 缓冲(OpenCV 原生解码,跳过 JS atob + upng) */
|
||
function decodeBase64PngToBgr(base64) {
|
||
let srcMat;
|
||
try {
|
||
srcMat = OpenCV.base64ToMat(base64);
|
||
}
|
||
catch (e) {
|
||
// OpenCV bridge may throw HostFunction for any PNG (8-bit or 16-bit).
|
||
// Fall back to pure JS decode whenever the file header is PNG.
|
||
if (isPngByBase64Magic(base64)) {
|
||
try {
|
||
return decodePngToBgrJS(base64);
|
||
}
|
||
catch (jsError) {
|
||
throw new Error(`JS PNG fallback also failed (base64 length ${base64?.length ?? 0}): ${jsError.message}`);
|
||
}
|
||
}
|
||
throw new Error(`OpenCV base64ToMat failed (base64 length ${base64?.length ?? 0}): ${e.message}`);
|
||
}
|
||
if (!srcMat || typeof srcMat.id !== 'string' || !srcMat.id) {
|
||
throw new Error(`OpenCV base64ToMat returned invalid Mat (base64 length ${base64?.length ?? 0})`);
|
||
}
|
||
const releaseIds = [srcMat.id];
|
||
try {
|
||
// 先解析 PNG 文件头(宽高、位深),供 ensureMat8U 在 16-bit toJSValue 崩溃时降级使用
|
||
let pngHeader;
|
||
try {
|
||
pngHeader = readPngHeaderFromBase64(base64);
|
||
}
|
||
catch {
|
||
// 非 PNG 或解析失败,不传 header,确保 retain 原有行为
|
||
}
|
||
let workMat;
|
||
let extraReleaseIds;
|
||
try {
|
||
const result = ensureMat8U(srcMat, pngHeader);
|
||
workMat = result.mat;
|
||
extraReleaseIds = result.extraReleaseIds;
|
||
}
|
||
catch (e) {
|
||
throw new Error(`ensureMat8U failed: ${e.message}`);
|
||
}
|
||
releaseIds.push(...extraReleaseIds);
|
||
const info = OpenCV.toJSValue(workMat);
|
||
const cols = info.cols;
|
||
const rows = info.rows;
|
||
let bgrMat = workMat;
|
||
if (info.type === DataTypes.CV_8UC4) {
|
||
bgrMat = OpenCV.createObject(ObjectType.Mat, rows, cols, DataTypes.CV_8UC3);
|
||
releaseIds.push(bgrMat.id);
|
||
OpenCV.invoke('cvtColor', workMat, bgrMat, ColorConversionCodes.COLOR_BGRA2BGR);
|
||
}
|
||
else if (info.type === DataTypes.CV_8UC1) {
|
||
bgrMat = OpenCV.createObject(ObjectType.Mat, rows, cols, DataTypes.CV_8UC3);
|
||
releaseIds.push(bgrMat.id);
|
||
OpenCV.invoke('cvtColor', workMat, bgrMat, ColorConversionCodes.COLOR_GRAY2BGR);
|
||
}
|
||
const continuous = OpenCV.invoke('clone', bgrMat);
|
||
releaseIds.push(continuous.id);
|
||
const { buffer, cols: outCols, rows: outRows } = OpenCV.matToBuffer(continuous, 'uint8');
|
||
return {
|
||
buffer: new Uint8Array(buffer),
|
||
cols: outCols,
|
||
rows: outRows,
|
||
};
|
||
}
|
||
finally {
|
||
OpenCV.releaseBuffers(releaseIds);
|
||
}
|
||
}
|
||
const pngBgrCache = new Map();
|
||
const prewarmInFlight = new Map();
|
||
export function prewarmPngBgrCache(paths) {
|
||
for (const path of paths) {
|
||
const filePath = normalizePath(path);
|
||
void readPngBgrBuffer(filePath).catch(() => { });
|
||
}
|
||
}
|
||
export async function prewarmPngBgrCacheAsync(paths) {
|
||
await Promise.all(paths.map(path => readPngBgrBuffer(normalizePath(path))));
|
||
}
|
||
export async function pngContentCacheKey(path) {
|
||
const stat = await RNFS.stat(normalizePath(path));
|
||
return `${stat.size}:${stat.mtime ?? stat.ctime}`;
|
||
}
|
||
export async function readPngBgrBuffer(path) {
|
||
const filePath = normalizePath(path);
|
||
const cacheKey = await pngContentCacheKey(filePath);
|
||
const cached = pngBgrCache.get(cacheKey);
|
||
if (cached) {
|
||
return cached;
|
||
}
|
||
const inflight = prewarmInFlight.get(cacheKey);
|
||
if (inflight) {
|
||
return inflight;
|
||
}
|
||
const loadPromise = (async () => {
|
||
const exists = await RNFS.exists(filePath);
|
||
if (!exists) {
|
||
throw new Error(`Image file does not exist: ${filePath}`);
|
||
}
|
||
const stat = await RNFS.stat(filePath);
|
||
if (stat.size === 0) {
|
||
throw new Error(`Image file is empty (0 bytes): ${filePath}`);
|
||
}
|
||
const base64 = await RNFS.readFile(filePath, 'base64');
|
||
if (!base64 || base64.length === 0) {
|
||
throw new Error(`Read base64 is empty: ${filePath}`);
|
||
}
|
||
let decoded;
|
||
try {
|
||
decoded = decodeBase64PngToBgr(base64);
|
||
}
|
||
catch (decodeError) {
|
||
throw new Error(`PNG decode failed: ${filePath} (${stat.size} bytes) - ${decodeError.message}`);
|
||
}
|
||
const entry = {
|
||
buffer: decoded.buffer,
|
||
cols: decoded.cols,
|
||
rows: decoded.rows,
|
||
};
|
||
pngBgrCache.set(cacheKey, entry);
|
||
return entry;
|
||
})();
|
||
prewarmInFlight.set(cacheKey, loadPromise);
|
||
try {
|
||
const result = await loadPromise;
|
||
return result;
|
||
}
|
||
finally {
|
||
prewarmInFlight.delete(cacheKey);
|
||
}
|
||
}
|
||
export function resizeBgrBuffer(buffer, srcCols, srcRows, dstCols, dstRows) {
|
||
if (srcCols === dstCols && srcRows === dstRows) {
|
||
return buffer;
|
||
}
|
||
const out = new Uint8Array(dstCols * dstRows * 3);
|
||
for (let y = 0; y < dstRows; y++) {
|
||
const sy = Math.min(srcRows - 1, Math.floor((y * srcRows) / dstRows));
|
||
for (let x = 0; x < dstCols; x++) {
|
||
const sx = Math.min(srcCols - 1, Math.floor((x * srcCols) / dstCols));
|
||
const si = (sy * srcCols + sx) * 3;
|
||
const di = (y * dstCols + x) * 3;
|
||
out[di] = buffer[si];
|
||
out[di + 1] = buffer[si + 1];
|
||
out[di + 2] = buffer[si + 2];
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
//# sourceMappingURL=pngImage.js.map
|