diff --git a/android/filament-utils-android/CMakeLists.txt b/android/filament-utils-android/CMakeLists.txt index 5dc82eb1a2..3e00ee0aaa 100644 --- a/android/filament-utils-android/CMakeLists.txt +++ b/android/filament-utils-android/CMakeLists.txt @@ -34,6 +34,10 @@ add_library(viewer STATIC IMPORTED) set_target_properties(viewer PROPERTIES IMPORTED_LOCATION ${FILAMENT_DIR}/lib/${ANDROID_ABI}/libviewer.a) +add_library(imagediff STATIC IMPORTED) +set_target_properties(imagediff PROPERTIES IMPORTED_LOCATION + ${FILAMENT_DIR}/lib/${ANDROID_ABI}/libimagediff.a) + add_library(civetweb STATIC IMPORTED) set_target_properties(civetweb PROPERTIES IMPORTED_LOCATION ${FILAMENT_DIR}/lib/${ANDROID_ABI}/libcivetweb.a) @@ -61,6 +65,7 @@ add_library(filament-utils-jni SHARED src/main/cpp/IBLPrefilterContext.cpp src/main/cpp/Utils.cpp src/main/cpp/Manipulator.cpp + src/main/cpp/ImageDiff.cpp src/main/cpp/RemoteServer.cpp ${IMAGEIO_DIR}/include/imageio/ImageDecoder.h @@ -78,6 +83,7 @@ target_include_directories(filament-utils-jni PRIVATE ${FILAMENT_DIR}/include ../../filament/backend/include ${IMAGEIO_DIR}/include + ../../libs/imagediff/include ../../libs/utils/include) set_target_properties(filament-utils-jni PROPERTIES LINK_DEPENDS ${VERSION_SCRIPT}) @@ -93,7 +99,9 @@ target_link_libraries(filament-utils-jni PRIVATE filament-jni PRIVATE ktxreader PRIVATE viewer + PRIVATE imagediff PRIVATE log PRIVATE utils PRIVATE perfetto # needed only when FILAMENT_ENABLE_PERFETTO is defined + PRIVATE jnigraphics # needed for AndroidBitmap_* functions in ImageDiff ) diff --git a/android/filament-utils-android/src/main/cpp/AutomationEngine.cpp b/android/filament-utils-android/src/main/cpp/AutomationEngine.cpp index 8b6204ad5d..b3d045b5a9 100644 --- a/android/filament-utils-android/src/main/cpp/AutomationEngine.cpp +++ b/android/filament-utils-android/src/main/cpp/AutomationEngine.cpp @@ -228,6 +228,18 @@ Java_com_google_android_filament_utils_AutomationEngine_nShouldClose(JNIEnv*, jc return automation->shouldClose(); } +extern "C" JNIEXPORT jint JNICALL +Java_com_google_android_filament_utils_AutomationEngine_nGetTestCount(JNIEnv*, jclass, jlong native) { + AutomationEngine* automation = (AutomationEngine*) native; + return (jint) automation->testCount(); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_google_android_filament_utils_AutomationEngine_nGetCurrentTest(JNIEnv*, jclass, jlong native) { + AutomationEngine* automation = (AutomationEngine*) native; + return (jint) automation->currentTest(); +} + extern "C" JNIEXPORT void JNICALL Java_com_google_android_filament_utils_AutomationEngine_nDestroy(JNIEnv*, jclass, jlong native) { AutomationEngine* automation = (AutomationEngine*) native; diff --git a/android/filament-utils-android/src/main/cpp/ImageDiff.cpp b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp new file mode 100644 index 0000000000..d08201bea8 --- /dev/null +++ b/android/filament-utils-android/src/main/cpp/ImageDiff.cpp @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include + +#include +#include + +#include + +using namespace imagediff; +using namespace utils; + +namespace { + +struct BitmapLock { + JNIEnv* env; + jobject bitmap; + void* pixels; + AndroidBitmapInfo info; + + BitmapLock(JNIEnv* env, jobject bitmap) : env(env), bitmap(bitmap), pixels(nullptr) { + if (!bitmap) return; + if (AndroidBitmap_getInfo(env, bitmap, &info) < 0) { + return; + } + if (AndroidBitmap_lockPixels(env, bitmap, &pixels) < 0) { + pixels = nullptr; + } + } + + ~BitmapLock() { + if (pixels) { + AndroidBitmap_unlockPixels(env, bitmap); + } + } + + bool isValid() const { return pixels != nullptr; } + + imagediff::Bitmap toBitmap() const { + return { + .width = (uint32_t) info.width, + .height = (uint32_t) info.height, + .stride = (size_t) info.stride, + .data = pixels + }; + } +}; + +} // namespace + + + +// Helper to convert C++ ImageDiffResult to Java Result +jobject createResult(JNIEnv* env, ImageDiffResult const& result, bool generateDiff) { + // Create Result class/objects + jclass resultClass = env->FindClass("com/google/android/filament/utils/ImageDiff$Result"); + jmethodID resultCtor = env->GetMethodID(resultClass, "", "()V"); + jobject resultObj = env->NewObject(resultClass, resultCtor); + jfieldID statusField = env->GetFieldID(resultClass, "status", "Lcom/google/android/filament/utils/ImageDiff$Result$Status;"); + jfieldID failingCountField = env->GetFieldID(resultClass, "failingPixelCount", "J"); + jfieldID maxDiffField = env->GetFieldID(resultClass, "maxDiffFound", "[F"); + jfieldID diffImageField = env->GetFieldID(resultClass, "diffImage", "Landroid/graphics/Bitmap;"); + + // Map Status enum + jclass statusEnum = env->FindClass("com/google/android/filament/utils/ImageDiff$Result$Status"); + jobject statusObj = nullptr; + jfieldID enumField = nullptr; + switch (result.status) { + case ImageDiffResult::Status::PASSED: + enumField = env->GetStaticFieldID(statusEnum, "PASSED", "Lcom/google/android/filament/utils/ImageDiff$Result$Status;"); + break; + case ImageDiffResult::Status::SIZE_MISMATCH: + enumField = env->GetStaticFieldID(statusEnum, "SIZE_MISMATCH", "Lcom/google/android/filament/utils/ImageDiff$Result$Status;"); + break; + case ImageDiffResult::Status::PIXEL_DIFFERENCE: + enumField = env->GetStaticFieldID(statusEnum, "PIXEL_DIFFERENCE", "Lcom/google/android/filament/utils/ImageDiff$Result$Status;"); + break; + } + statusObj = env->GetStaticObjectField(statusEnum, enumField); + env->SetObjectField(resultObj, statusField, statusObj); + + env->SetLongField(resultObj, failingCountField, (jlong) result.failingPixelCount); + + jfloatArray maxDiffArray = env->NewFloatArray(4); + env->SetFloatArrayRegion(maxDiffArray, 0, 4, result.maxDiffFound); + env->SetObjectField(resultObj, maxDiffField, maxDiffArray); + + if (generateDiff && result.diffImage.getWidth() > 0) { + jclass bitmapClass = env->FindClass("android/graphics/Bitmap"); + 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); + + if (diffBitmap) { + void* diffPixels; + if (AndroidBitmap_lockPixels(env, diffBitmap, &diffPixels) == 0) { + 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)); + } + } + AndroidBitmap_unlockPixels(env, diffBitmap); + env->SetObjectField(resultObj, diffImageField, diffBitmap); + } + } + } + + return resultObj; +} + +extern "C" JNIEXPORT jobject JNICALL +Java_com_google_android_filament_utils_ImageDiff_nCompareBasic(JNIEnv* env, jclass, + jobject refBitmap, jobject candBitmap, jint mode, jint swizzle, jint channelMask, + jfloat maxAbsDiff, jfloat maxFailingPixelsFraction, jobject maskBitmap) { + + BitmapLock refArg(env, refBitmap); + BitmapLock candArg(env, candBitmap); + BitmapLock maskArg(env, maskBitmap); + + if (!refArg.isValid() || !candArg.isValid()) { + ImageDiffResult emptyResult; + emptyResult.status = ImageDiffResult::Status::SIZE_MISMATCH; // or ERROR + return createResult(env, emptyResult, false); + } + + ImageDiffConfig config; + config.mode = (ImageDiffConfig::Mode) mode; + config.swizzle = (ImageDiffConfig::Swizzle) swizzle; + config.channelMask = (uint8_t) channelMask; + config.maxAbsDiff = maxAbsDiff; + config.maxFailingPixelsFraction = maxFailingPixelsFraction; + + imagediff::Bitmap const* maskPtr = nullptr; + imagediff::Bitmap maskVal; + if (maskBitmap && maskArg.isValid()) { + maskVal = maskArg.toBitmap(); + maskPtr = &maskVal; + } + + bool generateDiff = true; + ImageDiffResult result = compare(refArg.toBitmap(), candArg.toBitmap(), config, maskPtr, generateDiff); + + return createResult(env, result, generateDiff); +} + +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; + emptyResult.status = ImageDiffResult::Status::SIZE_MISMATCH; // or ERROR + return createResult(env, emptyResult, false); + } + + 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); + + if (!parsed) { + // Fallback to default or error? + // We could log error. + utils::slog.e << "ImageDiff JNI: Failed to parse JSON config" << utils::io::endl; + ImageDiffResult errResult; + errResult.status = ImageDiffResult::Status::PIXEL_DIFFERENCE; // assume fail + return createResult(env, errResult, false); + } + + imagediff::Bitmap const* maskPtr = nullptr; + imagediff::Bitmap maskVal; + if (maskBitmap && maskArg.isValid()) { + maskVal = maskArg.toBitmap(); + maskPtr = &maskVal; + } + + bool generateDiff = true; + ImageDiffResult result = compare(refArg.toBitmap(), candArg.toBitmap(), config, maskPtr, generateDiff); + + return createResult(env, result, generateDiff); +} + diff --git a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/AutomationEngine.java b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/AutomationEngine.java index 2d6c8fa368..9242918879 100644 --- a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/AutomationEngine.java +++ b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/AutomationEngine.java @@ -271,6 +271,9 @@ public class AutomationEngine { */ public boolean shouldClose() { return nShouldClose(mNativeObject); } + public int getTestCount() { return nGetTestCount(mNativeObject); } + public int getCurrentTest() { return nGetCurrentTest(mNativeObject); } + @Override protected void finalize() throws Throwable { nDestroy(mNativeObject); @@ -295,5 +298,7 @@ public class AutomationEngine { private static native void nSignalBatchMode(long nativeObject); private static native void nStopRunning(long nativeObject); private static native boolean nShouldClose(long nativeObject); + private static native int nGetTestCount(long nativeObject); + private static native int nGetCurrentTest(long nativeObject); private static native void nDestroy(long nativeObject); } diff --git a/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java new file mode 100644 index 0000000000..1b8e09a8a1 --- /dev/null +++ b/android/filament-utils-android/src/main/java/com/google/android/filament/utils/ImageDiff.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.filament.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.graphics.Bitmap; + +public class ImageDiff { + public enum Mode { + LEAF, AND, OR + } + + public enum Swizzle { + RGBA, BGRA + } + + public static class Config { + @NonNull + public Mode mode = Mode.LEAF; + @NonNull + public Swizzle swizzle = Swizzle.RGBA; + public int channelMask = 0xF; + public float maxAbsDiff = 0.0f; + public float maxFailingPixelsFraction = 0.0f; + // Children not supported in this simple wrapper for now, can be added if needed + } + + public static class Result { + public enum Status { + PASSED, + SIZE_MISMATCH, + PIXEL_DIFFERENCE + } + + public Status status; + public long failingPixelCount; + public float[] maxDiffFound; // [R, G, B, A] + public Bitmap diffImage; // Null if not generated + } + + /** + * Compares two bitmaps using a configuration object. + * + * @param reference Golden image + * @param candidate Actual image + * @param config Comparison configuration + * @param mask Optional mask (grayscale) + * @return Result of comparison + */ + @NonNull + public static Result compareBasic(@NonNull Bitmap reference, @NonNull Bitmap candidate, + @NonNull Config config, @Nullable Bitmap mask) { + return nCompareBasic(reference, candidate, config.mode.ordinal(), config.swizzle.ordinal(), + config.channelMask, config.maxAbsDiff, config.maxFailingPixelsFraction, mask); + } + + /** + * Compares two bitmaps using a JSON configuration string. + * + * @param reference Golden image + * @param candidate Actual image + * @param jsonConfig Comparison configuration in JSON format + * @param mask Optional mask (grayscale) + * @return Result of comparison + */ + @NonNull + public static Result compare(@NonNull Bitmap reference, @NonNull Bitmap candidate, + @NonNull String jsonConfig, @Nullable Bitmap mask) { + return nCompareJson(reference, candidate, jsonConfig, mask); + } + + private static native Result nCompareBasic(Bitmap reference, Bitmap candidate, int mode, int swizzle, + int channelMask, float maxAbsDiff, float maxFailingPixelsFraction, Bitmap mask); + + private static native Result nCompareJson(Bitmap reference, Bitmap candidate, String jsonConfig, Bitmap mask); +} diff --git a/android/samples/sample-render-validation/build.gradle b/android/samples/sample-render-validation/build.gradle new file mode 100644 index 0000000000..7892c473e6 --- /dev/null +++ b/android/samples/sample-render-validation/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +project.ext.isSample = true + +kotlin { + jvmToolchain(versions.jdk) +} + +android { + namespace 'com.google.android.filament.validation' + + compileSdkVersion versions.compileSdk + defaultConfig { + applicationId "com.google.android.filament.validation" + minSdkVersion versions.minSdk + targetSdkVersion versions.targetSdk + } + + compileOptions { + sourceCompatibility versions.jdk + targetCompatibility versions.jdk + } +} + +dependencies { + implementation deps.kotlin + implementation deps.coroutines.core + implementation project(':filament-android') + implementation project(':gltfio-android') + implementation project(':filament-utils-android') + implementation project(':filament-utils-android') +} diff --git a/android/samples/sample-render-validation/src/main/AndroidManifest.xml b/android/samples/sample-render-validation/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9fa5c2eb15 --- /dev/null +++ b/android/samples/sample-render-validation/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..7fd6deee9e --- /dev/null +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/MainActivity.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.filament.validation + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.view.Choreographer +import android.view.SurfaceView +import android.view.WindowManager +import android.widget.TextView +import android.widget.Toast +import com.google.android.filament.utils.ModelViewer +import com.google.android.filament.utils.Utils +import java.io.File + +class MainActivity : Activity(), ValidationRunner.Callback { + + companion object { + init { + Utils.init() + System.loadLibrary("filament-utils-jni") + } + private const val TAG = "FilamentValidation" + } + + private lateinit var surfaceView: SurfaceView + private lateinit var choreographer: Choreographer + private lateinit var modelViewer: ModelViewer + private lateinit var statusTextView: TextView + private var validationRunner: ValidationRunner? = null + + // Frame callback + private val frameScheduler = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + choreographer.postFrameCallback(this) + modelViewer.render(frameTimeNanos) + validationRunner?.onFrame(frameTimeNanos) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Simple layout + val layout = android.widget.FrameLayout(this) + surfaceView = SurfaceView(this) + layout.addView(surfaceView) + + statusTextView = TextView(this) + statusTextView.setTextColor(0xFFFFFFFF.toInt()) + statusTextView.textSize = 16f + statusTextView.setPadding(20, 20, 20, 20) + statusTextView.text = "Initializing..." + layout.addView(statusTextView) + + setContentView(layout) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + choreographer = Choreographer.getInstance() + modelViewer = ModelViewer(surfaceView) + + // Check permissions? + // We assume 'adb install -g' or permissions granted. + // But for scoped storage we might not need PERMISSION if reading from app-specific dirs, + // but user mentioned /sdcard/ so we need MANAGE_EXTERNAL_STORAGE or READ_EXTERNAL_STORAGE. + // For waiting/simplicity, we just try. + + handleIntent() + } + + private fun handleIntent() { + val intent = intent + val testConfigPath = intent.getStringExtra("test_config") + + if (testConfigPath != null) { + startValidation(testConfigPath) + } else { + statusTextView.text = "No test_config provided via Intent.\nUse -e test_config " + Log.w(TAG, "No test config provided") + } + } + + private fun startValidation(configPath: String) { + try { + Log.i(TAG, "Parsing config from $configPath") + val config = ConfigParser.parseFromPath(configPath) + + val outputDir = File(getExternalFilesDir(null), "validation_results") + Log.i(TAG, "Output dir: ${outputDir.absolutePath}") + + validationRunner = ValidationRunner(this, modelViewer, config, outputDir) + validationRunner?.callback = this + validationRunner?.start() + + } catch (e: Exception) { + Log.e(TAG, "Failed to start validation", e) + statusTextView.text = "Error: ${e.message}" + } + } + + override fun onResume() { + super.onResume() + choreographer.postFrameCallback(frameScheduler) + } + + override fun onPause() { + super.onPause() + choreographer.removeFrameCallback(frameScheduler) + } + + override fun onDestroy() { + super.onDestroy() + choreographer.removeFrameCallback(frameScheduler) + } + + override fun onTestFinished(result: ValidationRunner.TestResult) { + runOnUiThread { + val status = "Test ${result.name} finished: ${if(result.passed) "PASS" else "FAIL"}" + statusTextView.text = status + Log.i(TAG, status) + } + } + + override fun onAllTestsFinished() { + runOnUiThread { + statusTextView.text = "All tests finished!" + Log.i(TAG, "All tests finished") + // Optional: Auto-close activity? + // finish() + } + } + + override fun onStatusChanged(status: String) { + runOnUiThread { + statusTextView.text = status + } + } +} + diff --git a/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt new file mode 100644 index 0000000000..4794c19963 --- /dev/null +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/TestConfig.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.filament.validation + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.IOException + +data class RenderTestConfig( + val name: String, + val backends: List, + val models: Map, // name -> path + val tests: List +) + +data class TestConfig( + val name: String, + val description: String?, + val backends: List, + val models: Set, + val rendering: JSONObject, + val tolerance: JSONObject? +) + + +// See test/renderdiff/FORMAT.md for the full specification matched by this parser. +class ConfigParser { + companion object { + fun parseFromPath(path: String): RenderTestConfig { + val file = File(path) + val jsonTxt = removeComments(file.readText()) + val json = JSONObject(jsonTxt) + return parseRenderTestConfig(json, file.parentFile) + } + + private fun removeComments(json: String): String { + return json.lines().joinToString("\n") { it.substringBefore("//") } + } + + private fun parseRenderTestConfig(json: JSONObject, baseDir: File?): RenderTestConfig { + val name = json.getString("name") + val backends = json.getJSONArray("backends").toList() + + val modelSearchPaths = json.optJSONArray("model_search_paths")?.toList() ?: emptyList() + val models = mutableMapOf() + + baseDir?.let { dir -> + modelSearchPaths.forEach { searchPath -> + val searchDir = File(dir, searchPath) + if (searchDir.exists()) { + searchDir.walkTopDown().filter { it.isFile && (it.extension == "glb" || it.extension == "gltf") }.forEach { file -> + models[file.nameWithoutExtension] = file.absolutePath + } + } + } + } + + val presetsJson = json.optJSONArray("presets") + val presets = mutableMapOf() + if (presetsJson != null) { + for (i in 0 until presetsJson.length()) { + val p = parsePreset(presetsJson.getJSONObject(i), models.keys) + presets[p.name] = p + } + } + + val testsJson = json.getJSONArray("tests") + val tests = mutableListOf() + for (i in 0 until testsJson.length()) { + tests.add(parseTestConfig(testsJson.getJSONObject(i), models.keys, presets, backends)) + } + + return RenderTestConfig(name, backends, models, tests) + } + + private fun parsePreset(json: JSONObject, existingModels: Set): PresetConfig { + val name = json.getString("name") + val rendering = json.getJSONObject("rendering") + val models = json.optJSONArray("models")?.toList() ?: emptyList() + + // Validate models + models.forEach { if (!existingModels.contains(it)) throw IllegalArgumentException("Model $it not found") } + + val tolerance = json.optJSONObject("tolerance") + return PresetConfig(name, rendering, models, tolerance) + } + + private fun parseTestConfig( + json: JSONObject, + existingModels: Set, + presets: Map, + defaultBackends: List + ): TestConfig { + val name = json.getString("name") + val description = json.optString("description") + val backends = json.optJSONArray("backends")?.toList() ?: defaultBackends + + val applyPresets = json.optJSONArray("apply_presets")?.toList() ?: emptyList() + + val rendering = JSONObject() + val combinedModels = mutableSetOf() + var lastTolerance: JSONObject? = null + + applyPresets.forEach { presetName -> + val preset = presets[presetName] ?: throw IllegalArgumentException("Unknown preset $presetName") + // Merge rendering (flat copy) + val keys = preset.rendering.keys() + while(keys.hasNext()) { + val k = keys.next() + rendering.put(k, preset.rendering.get(k)) + } + combinedModels.addAll(preset.models) + if (preset.tolerance != null) lastTolerance = preset.tolerance + } + + val testRendering = json.optJSONObject("rendering") + if (testRendering != null) { + val keys = testRendering.keys() + while(keys.hasNext()) { + val k = keys.next() + rendering.put(k, testRendering.get(k)) + } + } + + val testModels = json.optJSONArray("models")?.toList() ?: emptyList() + combinedModels.addAll(testModels) + + // Validate models + combinedModels.forEach { if (!existingModels.contains(it)) throw IllegalArgumentException("Model $it not found") } + + val tolerance = json.optJSONObject("tolerance") ?: lastTolerance + + return TestConfig(name, description, backends, combinedModels, rendering, tolerance) + } + } +} + +data class PresetConfig( + val name: String, + val rendering: JSONObject, + val models: List, + val tolerance: JSONObject? +) + +private inline fun JSONArray.toList(): List { + val list = mutableListOf() + for (i in 0 until length()) { + list.add(get(i) as T) + } + return list +} 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 new file mode 100644 index 0000000000..3262721aee --- /dev/null +++ b/android/samples/sample-render-validation/src/main/java/com/google/android/filament/validation/ValidationRunner.kt @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.filament.validation + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import com.google.android.filament.Engine +import com.google.android.filament.Renderer +import com.google.android.filament.View +import com.google.android.filament.utils.AutomationEngine +import com.google.android.filament.utils.ImageDiff +import com.google.android.filament.utils.ModelViewer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class ValidationRunner( + private val context: Context, + private val modelViewer: ModelViewer, + private val config: RenderTestConfig, + private val outputDir: File +) { + + private var currentState = State.IDLE + private var currentTestIndex = 0 + private var currentModelIndex = 0 + private var currentEngine: AutomationEngine? = null + private var currentTestConfig: TestConfig? = null + private var currentModelName: String? = null + + private var loadStartFence: com.google.android.filament.Fence? = null + private var loadStartTime = 0L + + enum class State { + IDLE, + LOADING_MODEL, + WAITING_FOR_FENCE, + RUNNING_TEST, + COMPARING + } + + interface Callback { + fun onTestFinished(result: TestResult) + fun onAllTestsFinished() + fun onStatusChanged(status: String) + } + + var callback: Callback? = null + + fun start() { + if (config.tests.isEmpty()) { + callback?.onAllTestsFinished() + return + } + currentTestIndex = 0 + currentModelIndex = 0 + startTest(config.tests[0]) + } + + private fun startTest(test: TestConfig) { + currentTestConfig = test + if (test.models.isEmpty()) { + nextTest() + return + } + currentModelIndex = 0 + startModel(test.models.elementAt(0)) + } + + private fun startModel(modelName: String) { + currentModelName = modelName + val modelPath = config.models[modelName] + if (modelPath == null) { + Log.e("ValidationRunner", "Model $modelName not found") + nextModel() + return + } + + currentState = State.LOADING_MODEL + callback?.onStatusChanged("Loading $modelName for ${currentTestConfig?.name}") + + // Load model on main thread (required by ModelViewer) + // We assume this is called from main thread or we dispatch + loadModel(modelPath) + } + + private fun loadModel(path: String) { + // Assume called on Main Thread + modelViewer.destroyModel() + try { + val bytes = File(path).readBytes() + val buffer = ByteBuffer.wrap(bytes) + modelViewer.loadModelGlb(buffer) + modelViewer.transformToUnitCube() + loadStartFence = modelViewer.engine.createFence() + loadStartTime = System.nanoTime() + currentState = State.WAITING_FOR_FENCE + } catch (e: Exception) { + Log.e("ValidationRunner", "Failed to load $path", e) + nextModel() + } + } + + fun onFrame(frameTimeNanos: Long) { + when (currentState) { + State.IDLE -> {} + State.WAITING_FOR_FENCE -> { + loadStartFence?.let { fence -> + if (fence.wait(com.google.android.filament.Fence.Mode.FLUSH, 0) == com.google.android.filament.Fence.FenceStatus.CONDITION_SATISFIED) { + modelViewer.engine.destroyFence(fence) + loadStartFence = null + + // Compile materials (simplified) + modelViewer.scene.forEach { entity -> + // ... existing material compilation logic ... + } + + startAutomation() + } + } + } + State.RUNNING_TEST -> { + currentEngine?.let { engine -> + val content = AutomationEngine.ViewerContent() + content.view = modelViewer.view + content.renderer = modelViewer.renderer + content.scene = modelViewer.scene + content.lightManager = modelViewer.engine.lightManager + + // Tick + // Delta time? + val deltaTime = 1.0f / 60.0f // Fixed step for consistency? + engine.tick(modelViewer.engine, content, deltaTime) + + if (!engine.isRunning) { + // Test finished (for this spec) + currentState = State.COMPARING + captureAndCompare() + } + } + } + State.COMPARING -> {} // Busy + State.LOADING_MODEL -> {} + } + } + + private fun startAutomation() { + val test = currentTestConfig!! + val specJson = JSONObject() + specJson.put("name", test.name) + specJson.put("base", test.rendering) + val fullSpec = "[${specJson.toString()}]" + + currentEngine = AutomationEngine(fullSpec) + val options = AutomationEngine.Options() + options.sleepDuration = 0.0f // Minimal sleep, let frames drive it + options.minFrameCount = 5 // Ensure some frames pass + currentEngine?.setOptions(options) + currentEngine?.startRunning() + currentState = State.RUNNING_TEST + } + + + private fun captureAndCompare() { + callback?.onStatusChanged("Comparing ${currentTestConfig?.name}...") + val view = modelViewer.view + val renderer = modelViewer.renderer + val width = view.viewport.width + val height = view.viewport.height + + val buffer = ByteBuffer.allocateDirect(width * height * 4) + + val pbd = com.google.android.filament.Texture.PixelBufferDescriptor( + buffer, + com.google.android.filament.Texture.Format.RGBA, + com.google.android.filament.Texture.Type.UBYTE, + 1, 0, 0, 0, 0, // alignment, left, top, stride (0=default) + null // handler (null = current thread? no, handler is for callback) + ) { + // Callback when readPixels is done + // Dispatch to background thread for comparison to avoid blocking UI? + // "it" is undefined here? The callback interface is Runnable? + // Kotlin lambda for Runnable. + compareCapturedImage(buffer, width, height) + } + renderer.readPixels(0, 0, width, height, pbd) + } + + private fun compareCapturedImage(buffer: java.nio.Buffer, width: Int, height: Int) { + // This runs on... which thread? Filament driver thread possibly. + // We should use a helper to process. + + val testName = currentTestConfig!!.name + val modelName = currentModelName!! + val backend = "opengl" // Hardcoded for now, or get from View/Engine? + val testFullName = "${testName}.${backend}.${modelName}" + + // Golden path + // We expect a golden directory. + val goldenFile = File(config.models.get(modelName)!!).parentFile.parentFile.resolve("golden/${testFullName}.png") + // Strategy: models are in .../models/model.glb + // Goldens are in .../golden/ + + Thread { + try { + // Convert buffer to Bitmap + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(buffer) + + // Flip Y? ReadPixels is typically bottom-up? + // Filament readPixels is bottom-left? YES. + // Bitmap is top-left. + // We need to flip. + val matrix = android.graphics.Matrix() + matrix.postScale(1f, -1f) + val flipped = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true) + + var passed = false + if (goldenFile.exists()) { + val golden = android.graphics.BitmapFactory.decodeFile(goldenFile.absolutePath) + if (golden != null) { + // Populate tolerance from config + val tol = currentTestConfig?.tolerance ?: org.json.JSONObject() + val tolJson = tol.toString() + + val result = ImageDiff.compare(golden, flipped, tolJson, null) + passed = (result.status == ImageDiff.Result.Status.PASSED) + + // Save diff if failed? + if (!passed) { + val diffFile = File(outputDir, "${testFullName}_diff.png") + if (result.diffImage != null) { + FileOutputStream(diffFile).use { out -> + result.diffImage.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } + } + } else { + Log.e("ValidationRunner", "Failed to load golden: ${goldenFile.absolutePath}") + } + } else { + Log.w("ValidationRunner", "Golden not found: ${goldenFile.absolutePath}") + } + + // Save output + val outFile = File(outputDir, "${testFullName}.png") + FileOutputStream(outFile).use { out -> + flipped.compress(Bitmap.CompressFormat.PNG, 100, out) + } + + callback?.onTestFinished(TestResult(testFullName, passed)) + + // Schedule next model on main thread + // Use Handler or View.post + modelViewer.view.viewport + // dispatch nextModel() + android.os.Handler(android.os.Looper.getMainLooper()).post { + nextModel() + } + + } catch (e: Exception) { + Log.e("ValidationRunner", "Comparison failed", e) + android.os.Handler(android.os.Looper.getMainLooper()).post { nextModel() } + } + }.start() + } + + private fun nextModel() { + currentModelIndex++ + if (currentTestConfig != null && currentModelIndex < currentTestConfig!!.models.size) { + startModel(currentTestConfig!!.models.elementAt(currentModelIndex)) + } else { + nextTest() + } + } + + + private fun nextTest() { + currentTestIndex++ + if (currentTestIndex < config.tests.size) { + startTest(config.tests[currentTestIndex]) + } else { + currentState = State.IDLE + zipResults() + callback?.onAllTestsFinished() + } + } + + private fun zipResults() { + callback?.onStatusChanged("Zipping results...") + val zipFile = File(outputDir, "results.zip") + try { + java.util.zip.ZipOutputStream(java.io.FileOutputStream(zipFile)).use { zos -> + outputDir.walkTopDown().filter { it.isFile && it.name != "results.zip" }.forEach { file -> + val entryName = file.relativeTo(outputDir).path + zos.putNextEntry(java.util.zip.ZipEntry(entryName)) + file.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + } + } + Log.i("ValidationRunner", "Zipped results to ${zipFile.absolutePath}") + } catch (e: Exception) { + Log.e("ValidationRunner", "Failed to zip results", e) + } + } + + data class TestResult(val name: String, val passed: Boolean) + diff --git a/android/settings.gradle b/android/settings.gradle index 2d67281375..ba064b086f 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -21,5 +21,6 @@ include ':samples:sample-texture-view' include ':samples:sample-texture-target' include ':samples:sample-textured-object' include ':samples:sample-transparent-view' +include ':samples:sample-render-validation' rootProject.name = 'filament' diff --git a/libs/imagediff/CMakeLists.txt b/libs/imagediff/CMakeLists.txt index aaf668e8fb..d2729dd8ad 100644 --- a/libs/imagediff/CMakeLists.txt +++ b/libs/imagediff/CMakeLists.txt @@ -16,7 +16,10 @@ target_link_libraries(imagediff PRIVATE jsmn ) + if (NOT ANDROID AND NOT WEBGL AND NOT IOS) add_executable(test_imagediff tests/test_ImageDiff.cpp) target_link_libraries(test_imagediff PRIVATE imagediff gtest image utils) endif() + +install(TARGETS imagediff ARCHIVE DESTINATION lib/${DIST_ARCH} LIBRARY DESTINATION lib/${DIST_ARCH}) diff --git a/libs/imagediff/src/ImageDiff.cpp b/libs/imagediff/src/ImageDiff.cpp index 5aec2ee530..18d2028d42 100644 --- a/libs/imagediff/src/ImageDiff.cpp +++ b/libs/imagediff/src/ImageDiff.cpp @@ -25,6 +25,7 @@ #include // JSMN inclusion +#define JSMN_STATIC #include namespace imagediff {