P2 native tone DR/hl/sh (TONE_SKSL port) parity verified on Xiaomi (gates 140/75/209/104/11 pass)
This commit is contained in:
@@ -95,7 +95,7 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
|
||||
- [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)
|
||||
- [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS)
|
||||
- [x] P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms)
|
||||
- [ ] P2 tone
|
||||
- [x] P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3)
|
||||
- [ ] P3 clarity/grain
|
||||
- [ ] P4 frames/watermark
|
||||
- [ ] P5 hook export + flag
|
||||
|
||||
+96
-23
@@ -13,6 +13,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val TAG = "RecipesCamExport"
|
||||
|
||||
@@ -28,12 +30,19 @@ 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.
|
||||
// P1/P2 pipeline: decode -> optional crop -> optional tone (DR/hl/sh) ->
|
||||
// color matrix -> JPEG encode, all native, all off the JS/main threads.
|
||||
// matrix: row-major 4x5, translate column in 0..1 Skia space (folded from
|
||||
// colorUtils.ts in JS). crop: {x,y,width,height}|null. toneDr/toneHl/
|
||||
// toneSh: ToneUniforms from toneShader.ts (0/0/0 = skip tone pass).
|
||||
AsyncFunction("processPhotoAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map<String, Any?>?, toneDr: Double, toneHl: Double, toneSh: Double, quality: Int ->
|
||||
processPipeline(srcPath, dstPath, matrix, crop, toneDr, toneHl, toneSh, quality)
|
||||
}
|
||||
|
||||
// P1 alias (kept for the on-device matrix parity probe): matrix + crop, no
|
||||
// tone pass.
|
||||
AsyncFunction("processColorAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map<String, Any?>?, quality: Int ->
|
||||
processColorAsync(srcPath, dstPath, matrix, crop, quality)
|
||||
processPipeline(srcPath, dstPath, matrix, crop, 0.0, 0.0, 0.0, quality)
|
||||
}
|
||||
|
||||
// P0 probe helpers — removed before ship.
|
||||
@@ -62,41 +71,42 @@ 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 { ->
|
||||
// P1/P2 probe helper: materialize a solid RGB 64x64 PNG into cacheDir
|
||||
// (black/white/gray ramps used to gate tone/matrix math on-device).
|
||||
AsyncFunction("materializeSolidAsset") Coroutine { r: Int, g: Int, b: Int ->
|
||||
val ctx = appContext.reactContext ?: error("react context lost")
|
||||
val srcFile = File(ctx.cacheDir, "bench_black.png")
|
||||
val srcFile = File(ctx.cacheDir, "bench_solid_${r}_${g}_${b}.png")
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!srcFile.exists()) {
|
||||
val bmp = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
|
||||
bmp.eraseColor(android.graphics.Color.BLACK)
|
||||
bmp.eraseColor(android.graphics.Color.rgb(r, g, b))
|
||||
FileOutputStream(srcFile).use { fos ->
|
||||
check(bmp.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" }
|
||||
}
|
||||
bmp.recycle()
|
||||
}
|
||||
Log.i(TAG, "black asset -> ${srcFile.absolutePath}")
|
||||
Log.i(TAG, "solid asset -> ${srcFile.absolutePath}")
|
||||
}
|
||||
srcFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processColorAsync(
|
||||
private suspend fun processPipeline(
|
||||
srcPath: String,
|
||||
dstPath: String,
|
||||
matrixList: List<*>,
|
||||
crop: Map<String, Any?>?,
|
||||
toneDr: Double,
|
||||
toneHl: Double,
|
||||
toneSh: Double,
|
||||
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}")
|
||||
Log.i(TAG, "processPhoto start src=$srcPath crop=$crop tone=($toneDr,$toneHl,$toneSh) thread=${Thread.currentThread().name}")
|
||||
withContext(Dispatchers.IO) {
|
||||
val srcFile = File(srcPath)
|
||||
check(srcFile.exists()) { "source file missing: $srcPath" }
|
||||
@@ -118,10 +128,27 @@ private suspend fun processColorAsync(
|
||||
Log.i(TAG, "cropped to ${bmp.width}x${bmp.height}")
|
||||
}
|
||||
|
||||
// 2. Color matrix via paint pass onto a fresh ARGB_8888 bitmap.
|
||||
// 2. Tone pass (DR / highlight / shadow). Port of TONE_SKSL from
|
||||
// toneShader.ts — pure per-pixel function of luma, so no GPU needed:
|
||||
// decode to IntArray, factor f(t), write back. Alpha untouched.
|
||||
val t2 = System.nanoTime()
|
||||
var toneActive = toneDr != 0.0 || toneHl != 0.0 || toneSh != 0.0
|
||||
if (toneActive) {
|
||||
// decodeFile/crop bitmaps are immutable; applyTone mutates in place -> copy.
|
||||
if (!bmp.isMutable) {
|
||||
val mutable = bmp.copy(Bitmap.Config.ARGB_8888, true)
|
||||
bmp.recycle()
|
||||
bmp = mutable
|
||||
}
|
||||
applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat())
|
||||
timings["toneMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt()
|
||||
Log.i(TAG, "tone applied dr=$toneDr hl=$toneHl sh=$toneSh thread=${Thread.currentThread().name}")
|
||||
}
|
||||
|
||||
// 3. 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 t3 = System.nanoTime()
|
||||
val androidMatrix = FloatArray(20) { i ->
|
||||
if (i % 5 == 4) matrix[i] * 255f else matrix[i]
|
||||
}
|
||||
@@ -129,25 +156,71 @@ private suspend fun processColorAsync(
|
||||
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()
|
||||
timings["colorMs"] = ((System.nanoTime() - t3) / 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()
|
||||
// 4. JPEG encode + write.
|
||||
val t4 = 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}")
|
||||
timings["encodeWriteMs"] = ((System.nanoTime() - t4) / 1_000_000).toInt()
|
||||
Log.i(TAG, "processPhoto done dst=$dstPath size=${out.width}x${out.height} decodeMs=${timings["decodeMs"]} toneMs=${timings["toneMs"] ?: 0} 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"]}")
|
||||
Log.i(TAG, "processPhoto totalMs=${timings["totalMs"]}")
|
||||
return timings
|
||||
}
|
||||
|
||||
private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) {
|
||||
val w = bmp.width
|
||||
val h = bmp.height
|
||||
val px = IntArray(w * h)
|
||||
bmp.getPixels(px, 0, w, 0, 0, w, h)
|
||||
val n = px.size
|
||||
for (i in 0 until n) {
|
||||
val c = px[i]
|
||||
val r = ((c shr 16) and 0xFF) / 255f
|
||||
val g = ((c shr 8) and 0xFF) / 255f
|
||||
val b = (c and 0xFF) / 255f
|
||||
val t = (0.2126f * r + 0.7152f * g + 0.0722f * b).coerceIn(0f, 1f)
|
||||
val hlMask = smoothstep(0.80f, 1.00f, t)
|
||||
val shMask = 1f - smoothstep(0.00f, 0.30f, t)
|
||||
// NOTE: engine keeps 'out' as a reserved-word-free local.
|
||||
var o = t
|
||||
// Highlight: bright end only, multiplicative on both signs (symmetric region).
|
||||
if (hl >= 0f) {
|
||||
o *= 1f + hl * 0.30f * hlMask
|
||||
} else {
|
||||
o *= 1f + hl * 0.45f * hlMask
|
||||
}
|
||||
// Shadow: dark end only.
|
||||
if (sh >= 0f) {
|
||||
o += sh * 0.80f * shMask * (1f - t)
|
||||
} else {
|
||||
o *= 1f + sh * 0.80f * shMask
|
||||
}
|
||||
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
|
||||
o += dr * 0.12f * shMask * (1f - t)
|
||||
o -= dr * 0.18f * hlMask * t
|
||||
val f = (o / max(t, 0.0004f)).coerceIn(0f, 4f)
|
||||
val alpha = (c ushr 24) and 0xFF
|
||||
val nr = ((r * f).coerceIn(0f, 1f) * 255f).roundToInt()
|
||||
val ng = ((g * f).coerceIn(0f, 1f) * 255f).roundToInt()
|
||||
val nb = ((b * f).coerceIn(0f, 1f) * 255f).roundToInt()
|
||||
px[i] = (alpha shl 24) or (nr shl 16) or (ng shl 8) or nb
|
||||
}
|
||||
bmp.setPixels(px, 0, w, 0, 0, w, h)
|
||||
}
|
||||
|
||||
private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float {
|
||||
val tx = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
|
||||
return tx * tx * (3f - 2f * tx)
|
||||
}
|
||||
|
||||
private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map<String, Int> {
|
||||
val timings = mutableMapOf<String, Int>()
|
||||
val t0 = System.nanoTime()
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface DecodeEncodeTimings {
|
||||
|
||||
export interface ColorTimings {
|
||||
decodeMs: number;
|
||||
toneMs?: number;
|
||||
colorMs: number;
|
||||
encodeWriteMs: number;
|
||||
totalMs: number;
|
||||
@@ -21,20 +22,29 @@ export interface CropRect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** ToneUniforms from toneShader.ts (0/0/0 = skip tone pass). */
|
||||
export interface ToneUniforms {
|
||||
dr: number;
|
||||
hl: number;
|
||||
sh: 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.
|
||||
* Decode srcPath, optional crop rect, optional tone pass (DR/highlight/
|
||||
* shadow, port of toneShader.ts), then row-major 4x5 color matrix (translate
|
||||
* column in 0..1 Skia space), JPEG-encode to dstPath. All native, off the
|
||||
* JS/main threads. Stage order mirrors exportEngine: crop -> tone -> matrix.
|
||||
*/
|
||||
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, tone: ToneUniforms, quality: number): Promise<ColorTimings>;
|
||||
/** P1 alias: matrix + crop, no tone pass. */
|
||||
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>;
|
||||
materializeSolidAsset(r: number, g: number, b: number): Promise<string>;
|
||||
}
|
||||
|
||||
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
|
||||
|
||||
+33
-25
@@ -2,7 +2,12 @@ import RecipescamExport from '../../modules/recipescam-export';
|
||||
import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils';
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
|
||||
const RUNS = 3;
|
||||
const RUNS = 2;
|
||||
const IDENTITY = (() => {
|
||||
const m = new Array(20).fill(0);
|
||||
m[0] = m[6] = m[12] = m[18] = 1;
|
||||
return m;
|
||||
})();
|
||||
|
||||
const NEUTRAL_ADJ: ColorAdjustments = {
|
||||
exposure: 0,
|
||||
@@ -23,43 +28,46 @@ const NEUTRAL_ADJ: ColorAdjustments = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.*.
|
||||
* P0/P1/P2 probe harness (temporary — removed at P7).
|
||||
* Native logs (RecipesCamExport tag) are the only visible channel: release
|
||||
* Hermes strips console.*. Each gate prints its own centerPixel / timing lines.
|
||||
*/
|
||||
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 black = await m.materializeSolidAsset(0, 0, 0);
|
||||
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;
|
||||
// P2 tone parity on solid ramps (expected values computed from TONE_SKSL):
|
||||
// white(255) hl=-1 -> 140; gray64 sh=+1 -> 75; white dr=1 -> 209;
|
||||
// gray26 sh=+1 -> 104; gray26 hl=+1 sh=-1 -> 11.
|
||||
const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [
|
||||
{ rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' },
|
||||
{ rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' },
|
||||
{ rgb: [255, 255, 255], tone: { dr: 1, hl: 0, sh: 0 }, tag: 'white-dr1' },
|
||||
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray26-sh+1' },
|
||||
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 1, sh: -1 }, tag: 'gray26-hl+1-sh-1' },
|
||||
];
|
||||
for (const g of toneGates) {
|
||||
const solid = await m.materializeSolidAsset(g.rgb[0], g.rgb[1], g.rgb[2]);
|
||||
const out = solid.replace(/\.png$/, `-tone-${Date.now()}.jpg`);
|
||||
const t = await m.processPhotoAsync(solid, out, IDENTITY, null, g.tone.dr, g.tone.hl, g.tone.sh, 95);
|
||||
console.error('[BENCH] toneGate ' + g.tag + ' totalMs=' + t.totalMs + ' toneMs=' + (t.toneMs ?? 0));
|
||||
}
|
||||
|
||||
// Full-artwork run: provia + mild tone to sanity-check timing on 12MP.
|
||||
const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0);
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const t0 = Date.now();
|
||||
const dst = src.replace(/\.png$/, `-out-${Date.now()}-${i}.jpg`);
|
||||
const timings = await m.decodeEncodeAsync(src, dst, 95);
|
||||
console.error(
|
||||
'[BENCH] run=' + i + ' jsRoundTripMs=' + (Date.now() - t0) +
|
||||
' 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);
|
||||
const dst = src.replace(/\.png$/, `-full-${Date.now()}-${i}.jpg`);
|
||||
const t = await m.processPhotoAsync(src, dst, provia, null, 0.33, 0.3, 0.3, 95);
|
||||
console.error('[BENCH] fullRun=' + i + ' decodeMs=' + t.decodeMs + ' toneMs=' + (t.toneMs ?? 0) + ' colorMs=' + t.colorMs + ' encodeWriteMs=' + t.encodeWriteMs + ' totalMs=' + t.totalMs + ' pong=' + pong);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BENCH] failed', e);
|
||||
|
||||
Reference in New Issue
Block a user