From b2531fff152992883cbefca1b45a6127e29b86ba Mon Sep 17 00:00:00 2001 From: Powei Feng Date: Fri, 13 Mar 2026 11:39:22 -0700 Subject: [PATCH] android: [sample-render-val] add difference/output viewer (#9781) - Add viewer for closer examination - Add slider to enhance difference - Fixed ImageDiff jni bug to account for stride and premultiplication by alpha --- .../src/main/cpp/ImageDiff.cpp | 55 +++-- .../filament/validation/MainActivity.kt | 225 +++++++++++++++++- .../filament/validation/ValidationRunner.kt | 55 ++++- .../src/main/res/layout/activity_main.xml | 27 +++ .../main/res/layout/dialog_image_viewer.xml | 130 ++++++++++ 5 files changed, 460 insertions(+), 32 deletions(-) create mode 100644 android/samples/sample-render-validation/src/main/res/layout/dialog_image_viewer.xml diff --git a/android/filament-utils-android/src/main/cpp/ImageDiff.cpp b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp index d08201bea8..338c92abb9 100644 --- a/android/filament-utils-android/src/main/cpp/ImageDiff.cpp +++ b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp @@ -20,8 +20,6 @@ #include #include -#include - using namespace imagediff; using namespace utils; @@ -102,30 +100,48 @@ jobject createResult(JNIEnv* env, ImageDiffResult const& result, bool generateDi if (generateDiff && result.diffImage.getWidth() > 0) { jclass bitmapClass = env->FindClass("android/graphics/Bitmap"); - jmethodID createBitmap = env->GetStaticMethodID(bitmapClass, "createBitmap", + jmethodID createBitmap = env->GetStaticMethodID(bitmapClass, "createBitmap", "(IILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;"); - + jclass configClass = env->FindClass("android/graphics/Bitmap$Config"); jfieldID argb8888 = env->GetStaticFieldID(configClass, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); jobject configObj = env->GetStaticObjectField(configClass, argb8888); uint32_t width = result.diffImage.getWidth(); uint32_t height = result.diffImage.getHeight(); - jobject diffBitmap = env->CallStaticObjectMethod(bitmapClass, createBitmap, (jint)width, (jint)height, configObj); - + jobject diffBitmap = env->CallStaticObjectMethod(bitmapClass, createBitmap, (jint) width, + (jint) height, configObj); + if (diffBitmap) { + // We need to transport the bit differences accurately to the java side, so set + // premultiplied to false. From the java-side, if the bitmap is used to draw to a + // canvas, then client needs to set premultiplied to true again. + jmethodID setPremultiplied = env->GetMethodID(bitmapClass, "setPremultiplied", "(Z)V"); + if (setPremultiplied) { + env->CallVoidMethod(diffBitmap, setPremultiplied, JNI_FALSE); + } + void* diffPixels; if (AndroidBitmap_lockPixels(env, diffBitmap, &diffPixels) == 0) { + AndroidBitmapInfo info; + AndroidBitmap_getInfo(env, diffBitmap, &info); + float const* src = result.diffImage.getPixelRef(); uint8_t* dst = (uint8_t*) diffPixels; - uint32_t channels = result.diffImage.getChannels(); // usually 4 - - for (size_t i = 0; i < width * height; ++i) { - for (int c = 0; c < 4; ++c) { - float v = 0.0f; - if (c < channels) v = src[i * channels + c]; - if (c == 3 && channels < 4) v = 1.0f; // Alpha 1.0 if missing - dst[i * 4 + c] = (uint8_t) std::min(255.0f, std::max(0.0f, v * 255.0f)); + uint32_t const channels = result.diffImage.getChannels(); // usually 4 + + for (size_t y = 0; y < height; ++y) { + uint8_t* row = dst + y * info.stride; + for (size_t x = 0; x < width; ++x) { + size_t srcIdx = (y * width + x) * channels; + for (int c = 0; c < 4; ++c) { + float v = 0.0f; + if (c < channels) v = src[srcIdx + c]; + if (c == 3 && channels < 4) v = 1.0f; // Alpha 1.0 if missing + + row[x * 4 + c] = uint8_t( + std::min(255.0f, std::max(0.0f, std::round(v * 255.0f)))); + } } } AndroidBitmap_unlockPixels(env, diffBitmap); @@ -133,7 +149,7 @@ jobject createResult(JNIEnv* env, ImageDiffResult const& result, bool generateDi } } } - + return resultObj; } @@ -147,7 +163,7 @@ Java_com_google_android_filament_utils_ImageDiff_nCompareBasic(JNIEnv* env, jcla BitmapLock maskArg(env, maskBitmap); if (!refArg.isValid() || !candArg.isValid()) { - ImageDiffResult emptyResult; + ImageDiffResult emptyResult; emptyResult.status = ImageDiffResult::Status::SIZE_MISMATCH; // or ERROR return createResult(env, emptyResult, false); } @@ -175,13 +191,13 @@ Java_com_google_android_filament_utils_ImageDiff_nCompareBasic(JNIEnv* env, jcla extern "C" JNIEXPORT jobject JNICALL Java_com_google_android_filament_utils_ImageDiff_nCompareJson(JNIEnv* env, jclass, jobject refBitmap, jobject candBitmap, jstring jsonConfig, jobject maskBitmap) { - + BitmapLock refArg(env, refBitmap); BitmapLock candArg(env, candBitmap); BitmapLock maskArg(env, maskBitmap); if (!refArg.isValid() || !candArg.isValid()) { - ImageDiffResult emptyResult; + ImageDiffResult emptyResult; emptyResult.status = ImageDiffResult::Status::SIZE_MISMATCH; // or ERROR return createResult(env, emptyResult, false); } @@ -189,7 +205,7 @@ Java_com_google_android_filament_utils_ImageDiff_nCompareJson(JNIEnv* env, jclas ImageDiffConfig config; const char* nativeJson = env->GetStringUTFChars(jsonConfig, 0); size_t length = env->GetStringUTFLength(jsonConfig); - + bool parsed = parseConfig(nativeJson, length, &config); env->ReleaseStringUTFChars(jsonConfig, nativeJson); @@ -214,4 +230,3 @@ Java_com_google_android_filament_utils_ImageDiff_nCompareJson(JNIEnv* env, jclas return createResult(env, result, generateDiff); } - diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt index 636900b8a0..942a230465 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt @@ -66,10 +66,26 @@ class MainActivity : Activity(), ValidationRunner.Callback { private lateinit var inputManager: ValidationInputManager private var currentInput: ValidationInputManager.ValidationInput? = null + private var currentAlphaDiffBitmap: Bitmap? = null + private var globalEnhancementFactor: Float = 1.0f + + private data class TestImages( + val testName: String, + val golden: Bitmap?, + val rendered: Bitmap?, + val diff: Bitmap?, + val alphaDiff: Bitmap? + ) + + private val diffImageViews = mutableListOf() + // UI Elements private lateinit var runButton: Button private lateinit var loadButton: Button private lateinit var optionsButton: Button + private lateinit var enhancementContainer: LinearLayout + private lateinit var enhancementLabel: TextView + private lateinit var enhancementSlider: android.widget.SeekBar private var resultManager: ValidationResultManager? = null private var validationRunner: ValidationRunner? = null @@ -98,6 +114,19 @@ class MainActivity : Activity(), ValidationRunner.Callback { runButton = findViewById(R.id.run_button) loadButton = findViewById(R.id.load_button) optionsButton = findViewById(R.id.options_button) + enhancementContainer = findViewById(R.id.enhancement_container) + enhancementLabel = findViewById(R.id.enhancement_label) + enhancementSlider = findViewById(R.id.enhancement_slider) + + enhancementSlider.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: android.widget.SeekBar?, progress: Int, fromUser: Boolean) { + globalEnhancementFactor = 1.0f + (progress / 100f) * 49.0f + enhancementLabel.text = String.format(Locale.US, "Enhancement: %.1fx", globalEnhancementFactor) + applyGlobalEnhancement() + } + override fun onStartTrackingTouch(seekBar: android.widget.SeekBar?) {} + override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) {} + }) // Setup Run Button runButton.setOnClickListener { @@ -120,6 +149,7 @@ class MainActivity : Activity(), ValidationRunner.Callback { popup.menu.add(0, 3, 0, "Export Result") popup.menu.add(0, 4, 0, "Test ADB Info") popup.menu.add(0, 5, 0, "Result ADB Info") + popup.menu.add(0, 6, 0, "Toggle Enhancement Slider") popup.setOnMenuItemClickListener { item -> when (item.itemId) { @@ -133,6 +163,9 @@ class MainActivity : Activity(), ValidationRunner.Callback { 3 -> exportTestResultsAction() 4 -> showTestAdbInfo() 5 -> showResultAdbInfo() + 6 -> { + enhancementContainer.visibility = if (enhancementContainer.visibility == View.VISIBLE) View.GONE else View.VISIBLE + } } true } @@ -256,6 +289,7 @@ class MainActivity : Activity(), ValidationRunner.Callback { // Clear existing results UI and state resultsContainer.removeAllViews() + diffImageViews.clear() resultManager = null val newInput = ValidationInputManager.ValidationInput( @@ -337,6 +371,8 @@ class MainActivity : Activity(), ValidationRunner.Callback { private fun startValidation(input: ValidationInputManager.ValidationInput) { try { resultsContainer.removeAllViews() + diffImageViews.clear() + enhancementSlider.isEnabled = false Log.i(TAG, "Starting validation with config: ${input.config.name}") Log.i(TAG, "Output dir: ${input.outputDir.absolutePath}") @@ -420,7 +456,15 @@ class MainActivity : Activity(), ValidationRunner.Callback { val imagesRow = LinearLayout(this) imagesRow.orientation = LinearLayout.HORIZONTAL - fun addImage(label: String, bitmap: Bitmap?) { + val testImages = TestImages( + testName = result.testName, + golden = currentGoldenBitmap, + rendered = currentRenderedBitmap, + diff = currentDiffBitmap, + alphaDiff = currentAlphaDiffBitmap + ) + + fun addImage(label: String, bitmap: Bitmap?, isDiff: Boolean) { if (bitmap != null) { val container = LinearLayout(this) container.orientation = LinearLayout.VERTICAL @@ -436,16 +480,29 @@ class MainActivity : Activity(), ValidationRunner.Callback { iv.layoutParams = LinearLayout.LayoutParams(250, 250) // Smaller thumbnails iv.scaleType = ImageView.ScaleType.FIT_CENTER iv.setBackgroundColor(0xFF404040.toInt()) + + if (isDiff) { + diffImageViews.add(iv) + applyEnhancementToView(iv, globalEnhancementFactor) + } + + iv.setOnClickListener { + showImageDialog(testImages, label) + } + container.addView(iv) imagesRow.addView(container) } } - addImage("Rendered", currentRenderedBitmap) - addImage("Golden", currentGoldenBitmap) + addImage("Rendered", currentRenderedBitmap, false) + addImage("Golden", currentGoldenBitmap, false) if (!result.passed) { - addImage("Diff", currentDiffBitmap) + addImage("Diff", currentDiffBitmap, true) + } + if (currentAlphaDiffBitmap != null) { + addImage("Alpha Diff", currentAlphaDiffBitmap, true) } resultContainer.addView(imagesRow) @@ -455,12 +512,14 @@ class MainActivity : Activity(), ValidationRunner.Callback { currentRenderedBitmap = null currentGoldenBitmap = null currentDiffBitmap = null + currentAlphaDiffBitmap = null } } override fun onAllTestsFinished() { runOnUiThread { statusTextView.text = "All tests finished!" + enhancementSlider.isEnabled = true Log.i(TAG, "All tests finished " + if (currentInput?.autoExport == true) "Exporting bundle" else "x") if (currentInput?.autoExport == true) { @@ -523,7 +582,165 @@ class MainActivity : Activity(), ValidationRunner.Callback { "Diff" -> { currentDiffBitmap = bitmap } + "Alpha Diff" -> { + currentAlphaDiffBitmap = bitmap + } } } } + + private fun applyEnhancementToView(iv: ImageView, factor: Float) { + val cm = android.graphics.ColorMatrix() + cm.setScale(factor, factor, factor, 1.0f) + iv.colorFilter = android.graphics.ColorMatrixColorFilter(cm) + } + + private fun applyGlobalEnhancement() { + for (iv in diffImageViews) { + applyEnhancementToView(iv, globalEnhancementFactor) + } + } + + private fun showImageDialog(images: TestImages, initialLabel: String) { + val dialogView = layoutInflater.inflate(R.layout.dialog_image_viewer, null) + val dialog = AlertDialog.Builder(this) + .setView(dialogView) + .create() + + val titleView = dialogView.findViewById(R.id.dialog_title) + val typeView = dialogView.findViewById(R.id.dialog_image_type) + val imageView = dialogView.findViewById(R.id.dialog_image) + val btnClose = dialogView.findViewById(R.id.btn_close) + val btnReset = dialogView.findViewById(R.id.btn_reset) + val btnPrev = dialogView.findViewById(R.id.btn_prev) + val btnNext = dialogView.findViewById(R.id.btn_next) + + val enhancementContainer = dialogView.findViewById(R.id.dialog_enhancement_container) + val enhancementLabel = dialogView.findViewById(R.id.dialog_enhancement_label) + val enhancementSlider = dialogView.findViewById(R.id.dialog_enhancement_slider) + + titleView.text = images.testName + + val availableImages = mutableListOf>() + images.rendered?.let { availableImages.add(Pair("Rendered", it)) } + images.golden?.let { availableImages.add(Pair("Golden", it)) } + images.diff?.let { availableImages.add(Pair("Diff", it)) } + images.alphaDiff?.let { availableImages.add(Pair("Alpha Diff", it)) } + + if (availableImages.isEmpty()) return + + var currentIndex = availableImages.indexOfFirst { it.first == initialLabel } + if (currentIndex == -1) currentIndex = 0 + + var currentDialogEnhancement = globalEnhancementFactor + + val matrix = android.graphics.Matrix() + // Save initial values for translation tracking + var lastTouchX = 0f + var lastTouchY = 0f + var isDragging = false + + val scaleDetector = android.view.ScaleGestureDetector(this, object : android.view.ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: android.view.ScaleGestureDetector): Boolean { + matrix.postScale(detector.scaleFactor, detector.scaleFactor, detector.focusX, detector.focusY) + imageView.imageMatrix = matrix + return true + } + }) + + imageView.setOnTouchListener { _, event -> + scaleDetector.onTouchEvent(event) + when (event.actionMasked) { + android.view.MotionEvent.ACTION_DOWN -> { + lastTouchX = event.x + lastTouchY = event.y + isDragging = true + } + android.view.MotionEvent.ACTION_MOVE -> { + if (isDragging && !scaleDetector.isInProgress) { + val dx = event.x - lastTouchX + val dy = event.y - lastTouchY + matrix.postTranslate(dx, dy) + imageView.imageMatrix = matrix + } + lastTouchX = event.x + lastTouchY = event.y + } + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> { + isDragging = false + } + } + true + } + + fun updateView() { + val (label, bitmap) = availableImages[currentIndex] + typeView.text = label + imageView.setImageBitmap(bitmap) + (imageView.drawable as? android.graphics.drawable.BitmapDrawable)?.setAntiAlias(false) + (imageView.drawable as? android.graphics.drawable.BitmapDrawable)?.setFilterBitmap(false) + imageView.imageMatrix = matrix + + if (label == "Diff" || label == "Alpha Diff") { + enhancementContainer.visibility = View.VISIBLE + applyEnhancementToView(imageView, currentDialogEnhancement) + } else { + enhancementContainer.visibility = View.GONE + imageView.colorFilter = null + } + } + + fun resetMatrix() { + val drawable = imageView.drawable ?: return + val width = imageView.width.toFloat() + val height = imageView.height.toFloat() + val dw = drawable.intrinsicWidth.toFloat() + val dh = drawable.intrinsicHeight.toFloat() + + val scaleX = width / dw + val scaleY = height / dh + val scale = Math.min(scaleX, scaleY) + + val dx = (width - dw * scale) / 2f + val dy = (height - dh * scale) / 2f + + matrix.reset() + matrix.postScale(scale, scale) + matrix.postTranslate(dx, dy) + imageView.imageMatrix = matrix + } + + btnClose.setOnClickListener { dialog.dismiss() } + btnReset.setOnClickListener { resetMatrix() } + btnPrev.setOnClickListener { + currentIndex = (currentIndex - 1 + availableImages.size) % availableImages.size + updateView() + } + btnNext.setOnClickListener { + currentIndex = (currentIndex + 1) % availableImages.size + updateView() + } + + val defaultProgress = ((currentDialogEnhancement - 1.0f) / 49.0f * 100).toInt() + val safeProgress = Math.max(0, Math.min(100, defaultProgress)) + enhancementSlider.progress = safeProgress + enhancementLabel.text = String.format(Locale.US, "Enhance: %.1fx", currentDialogEnhancement) + + enhancementSlider.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: android.widget.SeekBar?, progress: Int, fromUser: Boolean) { + currentDialogEnhancement = 1.0f + (progress / 100f) * 49.0f + enhancementLabel.text = String.format(Locale.US, "Enhance: %.1fx", currentDialogEnhancement) + updateView() + } + override fun onStartTrackingTouch(seekBar: android.widget.SeekBar?) {} + override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) {} + }) + + imageView.post { + resetMatrix() + } + + updateView() + dialog.show() + } } diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt index 710327c24b..762e274542 100644 --- a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt @@ -115,10 +115,6 @@ class ValidationRunner( } fun onFrame(frameTimeNanos: Long) { - if (frameCounter % 60 == 0) { - Log.i("ValidationRunner", "onFrame: $currentState (frame: $frameCounter)") - } - when (currentState) { State.IDLE -> {} State.WAITING_FOR_RESOURCES -> { @@ -136,7 +132,6 @@ class ValidationRunner( } } State.RUNNING_TEST -> { - // Log.i("ValidationRunner", "Running test...") currentEngine?.let { engine -> val content = AutomationEngine.ViewerContent() content.view = modelViewer.view @@ -257,10 +252,54 @@ class ValidationRunner( passed = (result.status == ImageDiff.Result.Status.PASSED) diffMetric = result.failingPixelCount.toFloat() - if (!passed) { + if (!passed) { if (result.diffImage != null) { - callback?.onImageResult("Diff", result.diffImage!!) - resultManager.saveImage("${testFullName}_diff", result.diffImage!!) + val diffImg = result.diffImage!! + val width = diffImg.width + val height = diffImg.height + val pixels = IntArray(width * height) + diffImg.getPixels(pixels, 0, width, 0, 0, width, height) + + var hasAlphaDiff = false + val alphaPixels = IntArray(width * height) + + for (i in pixels.indices) { + val color = pixels[i] + + val a = android.graphics.Color.alpha(color) + val r = android.graphics.Color.red(color) + val g = android.graphics.Color.green(color) + val b = android.graphics.Color.blue(color) + + if (a > 0) { + hasAlphaDiff = true + } + + // Map alpha diff to grayscale RGB + alphaPixels[i] = android.graphics.Color.argb(255, a, a, a) + + // Force main diff image alpha to 255 + pixels[i] = android.graphics.Color.argb(255, r, g, b) + } + + // Apply updated pixels to diff image + diffImg.setPixels(pixels, 0, width, 0, 0, width, height) + + // The C++ ImageDiff code sets isPremultiplied to false so Android + // doesn't erase RGB diff values when Alpha diff is 0. However, Android's + // Canvas will crash if we try to draw a non-premultiplied bitmap. + // Since we just forced all alpha values to 255 (fully opaque) in the + // loop above, we can safely mark it as premultiplied again here. + diffImg.isPremultiplied = true + callback?.onImageResult("Diff", diffImg) + resultManager.saveImage("${testFullName}_diff", diffImg) + + if (hasAlphaDiff) { + val alphaDiffImg = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + alphaDiffImg.setPixels(alphaPixels, 0, width, 0, 0, width, height) + callback?.onImageResult("Alpha Diff", alphaDiffImg) + resultManager.saveImage("${testFullName}_alpha_diff", alphaDiffImg) + } } } } else { diff --git a/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml b/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml index 45034cc48e..461a6adc1c 100644 --- a/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml +++ b/android/samples/sample-render-validation/src/main/res/layout/activity_main.xml @@ -81,6 +81,33 @@ android:contentDescription="More Options" android:layout_marginStart="8dp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +