From 1fdf765d547e508465eaaaba48631f796075ed2e Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 17:06:54 +0700 Subject: [PATCH] P6 parity probe: same photo through legacy Skia + native engines, 3-panel composite+diff on Xiaomi --- App.tsx | 10 ++ .../export/RecipescamExportModule.kt | 82 ++++++++++++++ src/dev/parityProbe.ts | 107 ++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/dev/parityProbe.ts diff --git a/App.tsx b/App.tsx index 4977d44..6f0467d 100644 --- a/App.tsx +++ b/App.tsx @@ -155,6 +155,16 @@ export default function App() { } }, []); + // P6 parity probe: same source through legacy Skia and native engines + // (EXPO_PUBLIC_PARITY=1 builds only). + useEffect(() => { + if (process.env.EXPO_PUBLIC_PARITY === '1') { + import('./src/dev/parityProbe') + .then((m) => m.parityProbe()) + .catch((e) => console.error('[PARITY] effect failed', e)); + } + }, []); + // Save/restore session on app lifecycle changes useEffect(() => { const subscription = AppState.addEventListener('change', async (nextAppState) => { diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index 93f2b86..85b650b 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorMatrixColorFilter import android.graphics.Paint +import android.graphics.Rect import android.graphics.RectF import android.graphics.Typeface import android.util.Base64 @@ -215,6 +216,14 @@ class RecipescamExportModule : Module() { } srcFile.absolutePath } + + // P6 parity probe helper: downscale-decode the two engine outputs (legacy + // Skia vs native) and emit a 3-panel composite [legacy|native|diff(x8)] + // JPEG plus diff stats — probe-only, removed at P7. + AsyncFunction("parityCompareAsync") Coroutine { pathA: String, pathB: String, outPath: String, maxDim: Int -> + val ctx = appContext.reactContext ?: error("react context lost") + withContext(Dispatchers.IO) { parityCompare(ctx, pathA, pathB, outPath, maxDim) } + } } } @@ -1058,3 +1067,76 @@ private fun stampGeo( } return Triple(diff, amber, white) } + + +// P6 parity helper: decode both outputs downscaled to maxDim (power-of-two +// inSampleSize — both files land on the same grid), compute per-pixel abs +// diff on RGB, and write a 3-panel composite JPEG [A | B | diff*8 grayscale]. +// Returns diff stats for the JS log. Probe-only, removed at P7. +private fun decodeDownscaled(path: String, maxDim: Int): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + check(bounds.outWidth > 0 && bounds.outHeight > 0) { "decode bounds failed $path" } + var sample = 1 + val dim = max(bounds.outWidth, bounds.outHeight) + while (dim / (sample * 2) >= maxDim) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + return BitmapFactory.decodeFile(path, opts) ?: error("decode failed $path") +} + +private fun parityCompare(ctx: Context, pathA: String, pathB: String, outPath: String, maxDim: Int): Map { + val a = decodeDownscaled(pathA, maxDim) + val b = decodeDownscaled(pathB, maxDim) + val aW = a.width; val aH = a.height + val bW = b.width; val bH = b.height + val w = min(aW, bW) + val h = min(aH, bH) + val pa = IntArray(aW * aH).also { a.getPixels(it, 0, aW, 0, 0, aW, aH) } + val pb = IntArray(bW * bH).also { b.getPixels(it, 0, bW, 0, 0, bW, bH) } + var sum = 0L + var maxAbs = 0 + val gt = IntArray(3) + val diffColors = IntArray(w * h) + for (y in 0 until h) { + val rowA = y * aW + val rowB = y * bW + val rowD = y * w + for (x in 0 until w) { + val ca = pa[rowA + x] + val cb = pb[rowB + x] + val dR = abs(((ca shr 16) and 0xFF) - ((cb shr 16) and 0xFF)) + val dG = abs(((ca shr 8) and 0xFF) - ((cb shr 8) and 0xFF)) + val dB = abs((ca and 0xFF) - (cb and 0xFF)) + val mx = max(dR, max(dG, dB)); if (mx > maxAbs) maxAbs = mx + val avg = (dR + dG + dB) / 3 + sum += dR + dG + dB + if (avg > 3) gt[0]++; if (avg > 12) gt[1]++; if (avg > 32) gt[2]++ + val amp = min(avg * 8, 255) + diffColors[rowD + x] = (0xFF shl 24) or (amp shl 16) or (amp shl 8) or amp + } + } + val total = w.toLong() * h + val diffBmp = Bitmap.createBitmap(diffColors, w, h, Bitmap.Config.ARGB_8888) + val sep = 8 + val outBmp = Bitmap.createBitmap(w * 3 + sep * 2, h, Bitmap.Config.ARGB_8888) + val c = Canvas(outBmp) + c.drawColor(Color.WHITE) + val srcRect = Rect(0, 0, w, h) + c.drawBitmap(a, srcRect, Rect(0, 0, w, h), null) + c.drawBitmap(b, srcRect, Rect(w + sep, 0, w + sep + w, h), null) + c.drawBitmap(diffBmp, srcRect, Rect(2 * (w + sep), 0, 2 * (w + sep) + w, h), null) + FileOutputStream(File(outPath)).use { fos -> + check(outBmp.compress(Bitmap.CompressFormat.JPEG, 90, fos)) { "parity composite encode failed" } + } + a.recycle(); b.recycle(); diffBmp.recycle(); outBmp.recycle() + Log.i(TAG, "parityCompare $pathA vs $pathB -> $outPath panels=${w}x$h total=$total") + return mapOf( + "width" to w, "height" to h, + "aW" to aW, "aH" to aH, "bW" to bW, "bH" to bH, + "meanAbs" to (sum / (total * 3.0)), + "maxAbs" to maxAbs, + "gt3pct" to (gt[0] * 100.0 / total), + "gt12pct" to (gt[1] * 100.0 / total), + "gt32pct" to (gt[2] * 100.0 / total), + ) +} diff --git a/src/dev/parityProbe.ts b/src/dev/parityProbe.ts new file mode 100644 index 0000000..ebf7bae --- /dev/null +++ b/src/dev/parityProbe.ts @@ -0,0 +1,107 @@ +// P6 parity probe (temporary — removed at P7). Runs ONE source image through +// the legacy Skia engine (processAndExportPhoto) and the native Kotlin engine +// (processAndExportPhotoNative) with identical recipe/options, measures wall +// time per engine plus a JS-tick counter (legacy renders sync JSI → JS thread +// stalls; native runs off-thread → ticks keep firing), then asks the module to +// downscale-diff the two outputs and write a 3-panel composite +// [legacy|native|diff*8] JPEG into cache + gallery for on-device QA. +import RecipescamExport from '../../modules/recipescam-export'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as MediaLibrary from 'expo-media-library/legacy'; +import { processAndExportPhoto } from '../utils/exportEngine'; +import { processAndExportPhotoNative } from '../utils/nativeExport'; +import { DEFAULT_RECIPES } from '../utils/defaultRecipes'; + +const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); +const toUri = (p: string) => (/^(file|content|ph):\/\//.test(p) ? p : `file://${p}`); + +interface ParityCase { + tag: string; + recipeId: string; +} +const CASES: ParityCase[] = [ + // matrix + tone + CC + enhance (denoise/clarity/grain) — full core pipeline. + { tag: 'classic-neg', recipeId: 'classic-neg-default' }, + // matrix + mist (clarity<0) + classic-white frame + grain. + { tag: 'retro-amber-frame', recipeId: 'retro-amber-default' }, + // cinema seasonal grade + tone + enhance. + { tag: 'cinema-summer', recipeId: 'cinema-summer-default' }, +]; +// Library-style export (evFromCamera falsy → 2^EV matrix gain applies, as it +// does for gallery stills); aspect full so the whole frame is compared. +const OPTIONS = { dpi: 300, sharpen: true }; + +async function newestGalleryPhoto(): Promise { + try { + const perm = await MediaLibrary.requestPermissionsAsync(); + if (!perm.granted) return null; + const page = await MediaLibrary.getAssetsAsync({ + first: 1, + // @ts-ignore legacy SortByValue tuple shape + sortBy: [['creationTime', false]], + }); + const a = page.assets?.[0]; + return a && a.uri ? a.uri : null; + } catch (e) { + console.error('[PARITY] gallery read failed', e); + return null; + } +} + +export async function parityProbe(): Promise { + const m = RecipescamExport as any; + try { + const src = (await newestGalleryPhoto()) ?? toUri(await m.materializeBenchAsset('wallframe')); + console.error(`[PARITY] source=${src}`); + for (const c of CASES) { + const recipe = DEFAULT_RECIPES.find((r) => r.id === c.recipeId); + if (!recipe) { + console.error(`[PARITY] ${c.tag}: recipe ${c.recipeId} missing`); + continue; + } + const frameId = recipe.frameId ?? 'none'; + let ticks = 0; + const iv = setInterval(() => { + ticks += 1; + }, 40); + const t0 = Date.now(); + const legacy = await processAndExportPhoto(src, recipe, frameId, false, null, OPTIONS); + const legacyMs = Date.now() - t0; + const legacyTicks = ticks; + ticks = 0; + const t1 = Date.now(); + const native = await processAndExportPhotoNative(src, recipe, frameId, false, null, OPTIONS); + const nativeMs = Date.now() - t1; + const nativeTicks = ticks; + clearInterval(iv); + console.error( + `[PARITY] ${c.tag}: legacyMs=${legacyMs} ticks=${legacyTicks} uri=${legacy?.uri} | nativeMs=${nativeMs} ticks=${nativeTicks} uri=${native?.uri}` + ); + if (!legacy || !native) { + console.error(`[PARITY] ${c.tag}: missing engine output`); + continue; + } + const out = `${FileSystem.cacheDirectory}parity_${c.tag}_${Date.now()}.jpg`; + const res = await m.parityCompareAsync( + stripScheme(legacy.uri), + stripScheme(native.uri), + stripScheme(out), + 1280 + ); + let saved = 'not-saved'; + try { + const perm = await MediaLibrary.requestPermissionsAsync(); + if (perm.granted) { + const asset = await MediaLibrary.createAssetAsync(out); + saved = `asset=${asset.id}`; + } + } catch (e) { + console.warn('[PARITY] save composite failed', e); + } + console.error(`[PARITY] ${c.tag}: composite=${saved} stats=${JSON.stringify(res)}`); + } + console.error('[PARITY] done'); + } catch (e) { + console.error('[PARITY] failed', e); + } +}