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:
+95
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user