P1 native color matrix + crop (processColorAsync): parity verified on Xiaomi (classic-neg on black -> rgb(13,5,0), translate*255)

This commit is contained in:
2026-09-09 15:18:30 +07:00
parent dfcf0f3a28
commit d412f40861
4 changed files with 161 additions and 9 deletions
+1 -1
View File
@@ -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
@@ -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<String, Any?>?, 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<String, Any?>?,
quality: Int,
): Map<String, Int> {
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<String, Int>()
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<String, Int> {
val timings = mutableMapOf<String, Int>()
val t0 = System.nanoTime()
+23
View File
@@ -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<DecodeEncodeTimings>;
/**
* 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<ColorTimings>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
materializeBlackAsset(): Promise<string>;
}
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
+42 -8
View File
@@ -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<void> {
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<void> {
' 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);