P6 parity probe: same photo through legacy Skia + native engines, 3-panel composite+diff on Xiaomi
This commit is contained in:
@@ -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) => {
|
||||
|
||||
+82
@@ -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<String, Any> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user