From d412f408612929a80a13b9a0122cadcdb5417c05 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 15:18:30 +0700 Subject: [PATCH] P1 native color matrix + crop (processColorAsync): parity verified on Xiaomi (classic-neg on black -> rgb(13,5,0), translate*255) --- PLAN.md | 2 +- .../export/RecipescamExportModule.kt | 95 +++++++++++++++++++ modules/recipescam-export/index.ts | 23 +++++ src/dev/nativeBenchProbe.ts | 50 ++++++++-- 4 files changed, 161 insertions(+), 9 deletions(-) diff --git a/PLAN.md b/PLAN.md index 052b218..60fa1c4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -93,7 +93,7 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback. ## Checklist trạng thái - [x] Chốt quyết định D1–D4 - [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink) -- [ ] P0 benchmark trên Xiaomi (cần máy cắm USB; APK sẵn sàng) +- [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS) - [ ] P1 matrix - [ ] P2 tone - [ ] P3 clarity/grain 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 098c5e4..a0642fd 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 @@ -2,6 +2,9 @@ package com.locphamtran.recipescamera.export import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint import android.util.Log import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module @@ -25,6 +28,14 @@ class RecipescamExportModule : Module() { decodeEncodeAsync(srcPath, dstPath, quality) } + // P1: apply a 4x5 color matrix (already folded in JS from colorUtils.ts — + // getSkiaColorMatrix + applyExposureGain) to a decoded image, optionally + // after a center-largest aspect crop, then JPEG-encode. All native, all off + // the JS/main threads. crop: {x, y, width, height} or null. + AsyncFunction("processColorAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map?, quality: Int -> + processColorAsync(srcPath, dstPath, matrix, crop, quality) + } + // P0 probe helpers — removed before ship. AsyncFunction("ping") { -> Log.i(TAG, "ping called") @@ -50,9 +61,93 @@ class RecipescamExportModule : Module() { } srcFile.absolutePath } + + // P1 probe helper: materialize a solid-black 64x64 PNG into cacheDir. + // Color matrix parity check: a matrix with only translate offsets applied to + // black must yield (offset*255) per channel — verifies the native filter + // uses the same offset space as Skia's MakeMatrix. + AsyncFunction("materializeBlackAsset") Coroutine { -> + val ctx = appContext.reactContext ?: error("react context lost") + val srcFile = File(ctx.cacheDir, "bench_black.png") + withContext(Dispatchers.IO) { + if (!srcFile.exists()) { + val bmp = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + bmp.eraseColor(android.graphics.Color.BLACK) + FileOutputStream(srcFile).use { fos -> + check(bmp.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" } + } + bmp.recycle() + } + Log.i(TAG, "black asset -> ${srcFile.absolutePath}") + } + srcFile.absolutePath + } } } +private suspend fun processColorAsync( + srcPath: String, + dstPath: String, + matrixList: List<*>, + crop: Map?, + quality: Int, +): Map { + require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" } + val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f } + val timings = mutableMapOf() + val t0 = System.nanoTime() + Log.i(TAG, "processColor start src=$srcPath crop=$crop thread=${Thread.currentThread().name}") + withContext(Dispatchers.IO) { + val srcFile = File(srcPath) + check(srcFile.exists()) { "source file missing: $srcPath" } + + val t1 = System.nanoTime() + var bmp = BitmapFactory.decodeFile(srcPath) ?: error("decode failed: $srcPath") + timings["decodeMs"] = ((System.nanoTime() - t1) / 1_000_000).toInt() + + // 1. Aspect-ratio crop (center-largest, rect precomputed in JS like + // exportEngine #1b). Bitmap.createBitmap = exact copy of the rect. + if (crop != null) { + val x = (crop["x"] as? Number)?.toInt() ?: 0 + val y = (crop["y"] as? Number)?.toInt() ?: 0 + val w = (crop["width"] as? Number)?.toInt() ?: error("crop.width required") + val h = (crop["height"] as? Number)?.toInt() ?: error("crop.height required") + val cropped = Bitmap.createBitmap(bmp, x, y, w, h) + if (cropped !== bmp) bmp.recycle() + bmp = cropped + Log.i(TAG, "cropped to ${bmp.width}x${bmp.height}") + } + + // 2. Color matrix via paint pass onto a fresh ARGB_8888 bitmap. + // Skia/Android share row-major 4x5 semantics; the 5th column (translate) + // lives in 0..255 space on Android but 0..1 on Skia -> scale by 255 here. + val t2 = System.nanoTime() + val androidMatrix = FloatArray(20) { i -> + if (i % 5 == 4) matrix[i] * 255f else matrix[i] + } + val out = Bitmap.createBitmap(bmp.width, bmp.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + val paint = Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) } + canvas.drawBitmap(bmp, 0f, 0f, paint) + timings["colorMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt() + val center = out.getPixel(out.width / 2, out.height / 2) + Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})") + bmp.recycle() + + // 3. JPEG encode + write. + val t3 = System.nanoTime() + FileOutputStream(dstPath).use { fos -> + check(out.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" } + } + timings["encodeWriteMs"] = ((System.nanoTime() - t3) / 1_000_000).toInt() + Log.i(TAG, "processColor done dst=$dstPath size=${out.width}x${out.height} decodeMs=${timings["decodeMs"]} colorMs=${timings["colorMs"]} encodeWriteMs=${timings["encodeWriteMs"]} thread=${Thread.currentThread().name}") + out.recycle() + } + timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt() + Log.i(TAG, "processColor totalMs=${timings["totalMs"]}") + return timings +} + private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map { val timings = mutableMapOf() val t0 = System.nanoTime() diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index edfc882..8a92b0b 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -6,12 +6,35 @@ export interface DecodeEncodeTimings { totalMs: number; } +export interface ColorTimings { + decodeMs: number; + colorMs: number; + encodeWriteMs: number; + totalMs: number; +} + +/** Rect (pixel coords) of the center-largest aspect crop, computed in JS. */ +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + export interface RecipescamExportModule { /** Decode JPEG/PNG at srcPath (plain absolute path), re-encode JPEG q and write dstPath. All native, background. Returns stage timings in ms. */ decodeEncodeAsync(srcPath: string, dstPath: string, quality: number): Promise; + /** + * Decode srcPath, optional crop rect, apply a row-major 4x5 color matrix + * (translate column in 0..1 Skia space — folded from colorUtils.ts + * getSkiaColorMatrix/applyExposureGain in JS), JPEG-encode to dstPath. + * All native, off the JS/main threads. + */ + processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise; /** P0 probe helpers — removed before ship. */ ping(): Promise; materializeBenchAsset(assetName: string): Promise; + materializeBlackAsset(): Promise; } export default requireNativeModule('RecipesCamExport') as RecipescamExportModule; diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index 02ff975..90c2ae4 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -1,22 +1,51 @@ import RecipescamExport from '../../modules/recipescam-export'; +import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils'; +import { BaseFilter, ColorAdjustments } from '../types'; const RUNS = 3; +const NEUTRAL_ADJ: ColorAdjustments = { + exposure: 0, + contrast: 0, + saturation: 0, + temperature: 5500, + tint: 0, + highlight: 0, + shadow: 0, + denoise: 0, + clarity: 0, + grain: 0, + colorChrome: 'none', + colorChromeBlue: 'none', + dynamicRange: 100, + sharpening: 0, + exposureCompensation: 0, +}; + /** - * P0 spike harness (temporary — removed at P7). Runs native decode+encode on - * the bundled wallframe artwork (3117x4000, representative of a 12MP export) - * and logs per-stage ms proving the JS thread is not blocked while native work - * runs. Gated by EXPO_PUBLIC_BENCH=1. - * - * Release-build visibility is via native Log.i (RecipesCamExport tag) because - * Hermes release builds strip console.* entirely. JS-side dispatch gaps appear - * as the logcat deltas between consecutive decode "start" lines. + * P0/P1 probe harness (temporary — removed at P7). + * P0: native decode+encode on the bundled wallframe artwork (3117x4000). + * P1: native color matrix + crop path. Parity trick: classic-neg matrix applied + * to a pure-black image must yield translate*255 per channel in the native + * filter (Skia 0..1 space -> Android 0..255 space), i.e. ~(13, 5, 0); the + * center pixel is logged natively (RecipesCamExport tag) since release Hermes + * strips console.*. */ export async function nativeBenchProbe(): Promise { const m = RecipescamExport as any; try { const pong = await m.ping(); const src: string = await m.materializeBenchAsset('wallframe'); + // P1 parity: classic-neg matrix on black -> expect center ~(13,5,0). + const matrix = applyExposureGain(getSkiaColorMatrix('classic-neg' as BaseFilter, NEUTRAL_ADJ), 0); + const black = await m.materializeBlackAsset(); + const blackOut = black.replace(/\.png$/, `-neg-${Date.now()}.jpg`); + await m.processColorAsync(black, blackOut, matrix, null, 95); + console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(',')); + + // P0 runs (decode+encode) + P1 identity-matrix run on the real artwork. + const identity = new Array(20).fill(0); + identity[0] = identity[6] = identity[12] = identity[18] = 1; for (let i = 0; i < RUNS; i++) { const t0 = Date.now(); const dst = src.replace(/\.png$/, `-out-${Date.now()}-${i}.jpg`); @@ -26,6 +55,11 @@ export async function nativeBenchProbe(): Promise { ' decodeMs=' + timings.decodeMs + ' encodeWriteMs=' + timings.encodeWriteMs + ' totalMs=' + timings.totalMs + ' pong=' + pong ); + // P1 crop: center 2000x2000 window of the 3117x4000 artwork. + const crop = { x: Math.round((3117 - 2000) / 2), y: Math.round((4000 - 2000) / 2), width: 2000, height: 2000 }; + const colorDst = src.replace(/\.png$/, `-color-${Date.now()}-${i}.jpg`); + const ct = await m.processColorAsync(src, colorDst, identity, crop, 95); + console.error('[BENCH] colorRun=' + i + ' decodeMs=' + ct.decodeMs + ' colorMs=' + ct.colorMs + ' encodeWriteMs=' + ct.encodeWriteMs + ' totalMs=' + ct.totalMs); } } catch (e) { console.error('[BENCH] failed', e);