[Fix] (trace-replay): use SSIM for golden validation

This commit is contained in:
2026-06-23 21:26:55 +08:00
parent f0f6d1e5fa
commit 2f52264f01
14 changed files with 159 additions and 266 deletions
+3 -5
View File
@@ -32,8 +32,7 @@ target_frame target frame index, or -1
target_call target call number, or -1
width optional replay surface width override
height optional replay surface height override
tolerance allowed mismatching pixel count after fuzz is applied
fuzz_percent per-channel fuzz percentage; default is 20
ssim_threshold minimum SSIM required to pass; default is 0.99
crop_x optional compare crop x
crop_y optional compare crop y
crop_width optional compare crop width
@@ -48,7 +47,7 @@ Implementation notes:
- The native runner validates inputs, sets `MOBILEGL_BACKEND_TYPE`, loads `libMobileGL.so`, runs apitrace GL retrace, writes `actual.png`, and writes `result.json`.
- The runner uses a MobileGL-backed EGL window-system shim. GLX calls in PC traces are consumed by apitrace's GLX retrace frontend and mapped onto this EGL shim; the Android runner does not require or call a MobileGL GLX implementation.
- `DirectGLES` replays on an EGL pbuffer by default, avoiding Android `SurfaceView` lifetime coupling. `DirectVulkan` still uses the Activity surface because it needs a native window-backed Vulkan swapchain.
- Golden comparison is implemented in native C++ with libpng RGBA decode. The Java Activity only passes arguments and displays the native result, so the replay/compare core is not tied to Android UI or Bitmap APIs and can be ported to Linux.
- Golden comparison is implemented in native C++ with libpng RGBA decode and SSIM validation. The Java Activity only passes arguments and displays the native result, so the replay/compare core is not tied to Android UI or Bitmap APIs and can be ported to Linux.
- The plugin profile still excludes `libtrace_replay_runner.so`; normal plugin APK behavior is preserved.
- Set `MOBILEGL_RETRACE_USE_ANGLE=1` when running `trace-replay-ci.sh` to pass `use_angle=true` for DirectGLES. The APK must include `libEGL_angle.so` and `libGLESv2_angle.so` under its x86_64 native libraries. The Activity passes Android's `nativeLibraryDir` to native code as `MOBILEGL_RETRACE_ANGLE_DIR`.
@@ -68,7 +67,6 @@ adb shell am start -a top.mobilegl.plugin.TRACE_REPLAY \
--es diff_path /data/user/0/top.mobilegl.plugin.espryt.trace/files/trace-replay/output/app-diff.png \
--es backend DirectGLES \
--el target_call 31249 \
--ei tolerance 0 \
--ei fuzz_percent 20
--es ssim_threshold 0.99
adb shell run-as top.mobilegl.plugin.espryt.trace cat files/trace-replay/output/result.json
```
@@ -14,6 +14,7 @@
#include <cstring>
#include <exception>
#include <fstream>
#include <iomanip>
#include <limits>
#include <memory>
#include <sstream>
@@ -520,7 +521,6 @@ bool WriteDifferenceImage(const Result& result,
int y0,
int compareWidth,
int compareHeight,
int fuzz,
std::string& error) {
if (result.diffPath.empty()) {
return true;
@@ -548,7 +548,7 @@ bool WriteDifferenceImage(const Result& result,
ChannelValue(golden, imageX, imageY, 1));
int db = std::abs(ChannelValue(actual, imageX, imageY, 2) -
ChannelValue(golden, imageX, imageY, 2));
bool different = dr > fuzz || dg > fuzz || db > fuzz;
bool different = dr != 0 || dg != 0 || db != 0;
std::uint8_t* dst = diff.pixels.data() +
(static_cast<std::size_t>(imageY) * diff.width + imageX) * 4;
if (different) {
@@ -569,17 +569,71 @@ bool WriteDifferenceImage(const Result& result,
struct GoldenComparison {
std::string path;
RgbaImage image;
long long mismatchPixels = std::numeric_limits<long long>::max();
double ssim = -1.0;
long long mismatchPixels = 0;
int x0 = 0;
int y0 = 0;
int compareWidth = 0;
int compareHeight = 0;
};
double ComputeChannelSsim(const RgbaImage& actual,
const RgbaImage& golden,
int x0,
int y0,
int compareWidth,
int compareHeight,
unsigned channel) {
const double count = static_cast<double>(compareWidth) * static_cast<double>(compareHeight);
double sumA = 0.0;
double sumG = 0.0;
double sumAA = 0.0;
double sumGG = 0.0;
double sumAG = 0.0;
for (int y = 0; y < compareHeight; ++y) {
for (int x = 0; x < compareWidth; ++x) {
const double a = ChannelValue(actual, x0 + x, y0 + y, channel);
const double g = ChannelValue(golden, x0 + x, y0 + y, channel);
sumA += a;
sumG += g;
sumAA += a * a;
sumGG += g * g;
sumAG += a * g;
}
}
const double meanA = sumA / count;
const double meanG = sumG / count;
const double varianceA = std::max(0.0, sumAA / count - meanA * meanA);
const double varianceG = std::max(0.0, sumGG / count - meanG * meanG);
const double covariance = sumAG / count - meanA * meanG;
constexpr double kC1 = 6.5025; // (0.01 * 255)^2
constexpr double kC2 = 58.5225; // (0.03 * 255)^2
const double luminance = (2.0 * meanA * meanG + kC1) /
(meanA * meanA + meanG * meanG + kC1);
const double contrastStructure = (2.0 * covariance + kC2) /
(varianceA + varianceG + kC2);
return luminance * contrastStructure;
}
double ComputeRgbSsim(const RgbaImage& actual,
const RgbaImage& golden,
int x0,
int y0,
int compareWidth,
int compareHeight) {
double sum = 0.0;
for (unsigned channel = 0; channel < 3; ++channel) {
sum += ComputeChannelSsim(actual, golden, x0, y0, compareWidth, compareHeight, channel);
}
return sum / 3.0;
}
bool CompareAgainstOneGolden(const Request& request,
const RgbaImage& actual,
const std::string& goldenPath,
int fuzz,
GoldenComparison& comparison,
std::string& error) {
if (!Exists(goldenPath)) {
@@ -616,27 +670,28 @@ bool CompareAgainstOneGolden(const Request& request,
return false;
}
long long mismatch = 0;
long long exactMismatch = 0;
for (int y = 0; y < compareHeight; ++y) {
for (int x = 0; x < compareWidth; ++x) {
bool different = false;
for (unsigned c = 0; c < 3; ++c) {
int a = ChannelValue(actual, x0 + x, y0 + y, c);
int g = ChannelValue(golden, x0 + x, y0 + y, c);
if (std::abs(a - g) > fuzz) {
if (a != g) {
different = true;
break;
}
}
if (different) {
++mismatch;
++exactMismatch;
}
}
}
comparison.path = goldenPath;
comparison.image = std::move(golden);
comparison.mismatchPixels = mismatch;
comparison.ssim = ComputeRgbSsim(actual, comparison.image, x0, y0, compareWidth, compareHeight);
comparison.mismatchPixels = exactMismatch;
comparison.x0 = x0;
comparison.y0 = y0;
comparison.compareWidth = compareWidth;
@@ -659,6 +714,7 @@ bool CompareWithGolden(const Request& request, Result& result) {
result.passed = true;
result.statusCode = STATUS_OK;
result.message = "retrace completed; golden_path was not provided";
result.ssim = 1.0;
result.mismatchPixels = 0;
return true;
}
@@ -671,18 +727,17 @@ bool CompareWithGolden(const Request& request, Result& result) {
return false;
}
const int fuzz = std::max(0, std::min(100, request.fuzzPercent)) * 255 / 100;
GoldenComparison bestComparison;
std::string comparisonError;
bool hasComparison = false;
for (const auto& goldenPath : goldenPaths) {
GoldenComparison comparison;
std::string error;
if (!CompareAgainstOneGolden(request, actual, goldenPath, fuzz, comparison, error)) {
if (!CompareAgainstOneGolden(request, actual, goldenPath, comparison, error)) {
comparisonError = error;
continue;
}
if (!hasComparison || comparison.mismatchPixels < bestComparison.mismatchPixels) {
if (!hasComparison || comparison.ssim > bestComparison.ssim) {
bestComparison = std::move(comparison);
hasComparison = true;
}
@@ -696,20 +751,22 @@ bool CompareWithGolden(const Request& request, Result& result) {
std::string diffError;
if (!WriteDifferenceImage(result, actual, bestComparison.image, bestComparison.x0, bestComparison.y0,
bestComparison.compareWidth, bestComparison.compareHeight, fuzz, diffError)) {
bestComparison.compareWidth, bestComparison.compareHeight, diffError)) {
result.statusCode = STATUS_IO_ERROR;
result.message = diffError.empty() ? "failed to write diff PNG" : diffError;
return false;
}
result.ssim = bestComparison.ssim;
result.mismatchPixels = bestComparison.mismatchPixels;
result.matchedGoldenPath = bestComparison.path;
result.passed = bestComparison.mismatchPixels <= request.tolerance;
result.passed = bestComparison.ssim >= request.ssimThreshold;
result.statusCode = result.passed ? STATUS_OK : STATUS_COMPARE_FAILED;
std::ostringstream message;
message << "retrace completed; mismatchPixels=" << bestComparison.mismatchPixels
<< ", tolerance=" << request.tolerance
<< ", fuzzPercent=" << request.fuzzPercent
message << std::fixed << std::setprecision(6)
<< "retrace completed; ssim=" << bestComparison.ssim
<< ", ssimThreshold=" << request.ssimThreshold
<< ", mismatchPixels=" << bestComparison.mismatchPixels
<< ", matchedGoldenPath=" << bestComparison.path;
result.message = message.str();
return result.passed;
@@ -753,8 +810,9 @@ bool WriteResultJson(const Request& request, const Result& result) {
file << " \"cropY\": " << request.cropY << ",\n";
file << " \"cropWidth\": " << request.cropWidth << ",\n";
file << " \"cropHeight\": " << request.cropHeight << ",\n";
file << " \"tolerance\": " << request.tolerance << ",\n";
file << " \"fuzzPercent\": " << request.fuzzPercent << ",\n";
file << std::fixed << std::setprecision(9);
file << " \"ssim\": " << result.ssim << ",\n";
file << " \"ssimThreshold\": " << request.ssimThreshold << ",\n";
file << " \"useAngle\": " << (UseAngleForRequest(request) ? "true" : "false") << ",\n";
file << " \"mismatchPixels\": " << result.mismatchPixels << "\n";
file << "}\n";
@@ -32,8 +32,7 @@ struct Request {
int cropY = 0;
int cropWidth = 0;
int cropHeight = 0;
int tolerance = 0;
int fuzzPercent = 20;
double ssimThreshold = 0.99;
bool useAngle = false;
};
@@ -45,6 +44,7 @@ struct Result {
std::string actualPath;
std::string diffPath;
std::string matchedGoldenPath;
double ssim = -1.0;
long long mismatchPixels = -1;
};
@@ -93,12 +93,11 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
jlong targetCall,
jint width,
jint height,
jint tolerance,
jdouble ssimThreshold,
jint cropX,
jint cropY,
jint cropWidth,
jint cropHeight,
jint fuzzPercent,
jstring angleLibraryDir,
jboolean useAngle) {
mobilegl_trace::Request request;
@@ -116,12 +115,11 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
request.targetCall = targetCall;
request.width = width;
request.height = height;
request.tolerance = tolerance;
request.ssimThreshold = ssimThreshold;
request.cropX = cropX;
request.cropY = cropY;
request.cropWidth = cropWidth;
request.cropHeight = cropHeight;
request.fuzzPercent = fuzzPercent;
request.useAngle = useAngle == JNI_TRUE;
ScopedTraceReplayState replayState;
@@ -106,12 +106,11 @@ public final class TraceReplayActivity extends Activity {
request.targetCall,
request.width,
request.height,
request.tolerance,
request.ssimThreshold,
request.cropX,
request.cropY,
request.cropWidth,
request.cropHeight,
request.fuzzPercent,
request.angleLibraryDir,
request.useAngle
);
@@ -135,12 +134,11 @@ public final class TraceReplayActivity extends Activity {
long targetCall,
int width,
int height,
int tolerance,
double ssimThreshold,
int cropX,
int cropY,
int cropWidth,
int cropHeight,
int fuzzPercent,
String angleLibraryDir,
boolean useAngle
);
@@ -156,12 +154,11 @@ public final class TraceReplayActivity extends Activity {
final long targetCall;
final int width;
final int height;
final int tolerance;
final double ssimThreshold;
final int cropX;
final int cropY;
final int cropWidth;
final int cropHeight;
final int fuzzPercent;
final String angleLibraryDir;
final boolean useAngle;
@@ -176,12 +173,11 @@ public final class TraceReplayActivity extends Activity {
long targetCall,
int width,
int height,
int tolerance,
double ssimThreshold,
int cropX,
int cropY,
int cropWidth,
int cropHeight,
int fuzzPercent,
String angleLibraryDir,
boolean useAngle
) {
@@ -195,12 +191,11 @@ public final class TraceReplayActivity extends Activity {
this.targetCall = targetCall;
this.width = width;
this.height = height;
this.tolerance = tolerance;
this.ssimThreshold = ssimThreshold;
this.cropX = cropX;
this.cropY = cropY;
this.cropWidth = cropWidth;
this.cropHeight = cropHeight;
this.fuzzPercent = fuzzPercent;
this.angleLibraryDir = angleLibraryDir;
this.useAngle = useAngle;
}
@@ -219,12 +214,11 @@ public final class TraceReplayActivity extends Activity {
intent.getLongExtra("target_call", -1L),
intent.getIntExtra("width", 0),
intent.getIntExtra("height", 0),
intent.getIntExtra("tolerance", 0),
readDouble(intent, "ssim_threshold", 0.99),
intent.getIntExtra("crop_x", 0),
intent.getIntExtra("crop_y", 0),
intent.getIntExtra("crop_width", 0),
intent.getIntExtra("crop_height", 0),
intent.getIntExtra("fuzz_percent", 20),
readString(intent, "angle_library_dir", nativeLibraryDir),
intent.getBooleanExtra("use_angle", false)
);
@@ -234,6 +228,18 @@ public final class TraceReplayActivity extends Activity {
String value = intent.getStringExtra(key);
return value == null ? fallback : value;
}
private static double readDouble(Intent intent, String key, double fallback) {
String stringValue = intent.getStringExtra(key);
if (stringValue != null && !stringValue.isEmpty()) {
try {
return Double.parseDouble(stringValue);
} catch (NumberFormatException ignored) {
return fallback;
}
}
return intent.getDoubleExtra(key, fallback);
}
}
public static final class TraceReplayResult {
+6 -11
View File
@@ -21,12 +21,11 @@ Usage:
--target-call N \
--width N \
--height N \
--tolerance N \
--ssim-threshold N \
--crop-x N \
--crop-y N \
--crop-width N \
--crop-height N \
--fuzz-percent N \
--timeout-seconds N
Set MOBILEGL_RETRACE_USE_ANGLE=1 to run DirectGLES replay with packaged ANGLE
@@ -68,12 +67,11 @@ alternate_golden_path=""
target_call=""
width=""
height=""
tolerance=""
ssim_threshold=""
crop_x=""
crop_y=""
crop_width=""
crop_height=""
fuzz_percent=""
timeout_seconds=""
while [ "$#" -gt 0 ]; do
@@ -99,12 +97,11 @@ while [ "$#" -gt 0 ]; do
--target-call) target_call="$(next_arg "$@")"; shift 2 ;;
--width) width="$(next_arg "$@")"; shift 2 ;;
--height) height="$(next_arg "$@")"; shift 2 ;;
--tolerance) tolerance="$(next_arg "$@")"; shift 2 ;;
--ssim-threshold) ssim_threshold="$(next_arg "$@")"; shift 2 ;;
--crop-x) crop_x="$(next_arg "$@")"; shift 2 ;;
--crop-y) crop_y="$(next_arg "$@")"; shift 2 ;;
--crop-width) crop_width="$(next_arg "$@")"; shift 2 ;;
--crop-height) crop_height="$(next_arg "$@")"; shift 2 ;;
--fuzz-percent) fuzz_percent="$(next_arg "$@")"; shift 2 ;;
--timeout-seconds) timeout_seconds="$(next_arg "$@")"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown argument: $1" ;;
@@ -123,12 +120,11 @@ require_value "${golden_path}" "--golden"
require_value "${target_call}" "--target-call"
require_value "${width}" "--width"
require_value "${height}" "--height"
require_value "${tolerance}" "--tolerance"
require_value "${ssim_threshold}" "--ssim-threshold"
require_value "${crop_x}" "--crop-x"
require_value "${crop_y}" "--crop-y"
require_value "${crop_width}" "--crop-width"
require_value "${crop_height}" "--crop-height"
require_value "${fuzz_percent}" "--fuzz-percent"
require_value "${timeout_seconds}" "--timeout-seconds"
test -f "${apk_file}" || die "APK does not exist: ${apk_file}"
@@ -228,12 +224,11 @@ run_retrace() {
--el target_call "${target_call}" \
--ei width "${width}" \
--ei height "${height}" \
--ei tolerance "${tolerance}" \
--es ssim_threshold "${ssim_threshold}" \
--ei crop_x "${crop_x}" \
--ei crop_y "${crop_y}" \
--ei crop_width "${crop_width}" \
--ei crop_height "${crop_height}" \
--ei fuzz_percent "${fuzz_percent}"
--ei crop_height "${crop_height}"
"${ADB}" shell "$@"
app_exited=0