Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Lights, Camera, Snapshot! Paparazzi 2.0 📸

Lights, Camera, Snapshot! Paparazzi 2.0 📸

Paparazzi 2.0 development has been underway since last year's Droidcon NYC:

In this session, we'll discuss:
* what APIs the 2.0 release will offer
* how to have more control with animation snapshots with a proposed keyframe and touch gestures API
* how to tackle fidelity issues between real devices and layoutlib using diffing algorithms
* ...and more!

We'll also give a demo of these 2.0 features, as well as why we're excited about it and you should be too!

Avatar for John Rodriguez

John Rodriguez

June 26, 2025
Tweet

More Decks by John Rodriguez

Other Decks in Technology

Transcript

  1. val engine = LayoutLibEngine() val sdk = PaparazziSdk().withEngine(engine) val snapshotHandler

    = Recorder() val view = ... // legacy view or wrapped composable val snapshot = sdk.snapshot(view) val testRecord = TestRecord( testClass = "my_test", testMethod = "my_method", snapshot = Path("./my_path"), ) snapshotHandler.handleSnapshot(snapshot, testRecord)
  2. PixelPerfect fun compare(expected: Image, actual: Image): DiffResult { // assume

    same width and height var differentPixels: Long = 0 for (y in 0 until height) { for (x in 0 until width) { val expectedRgb = expected.getRGB(x, y) val actualRgb = actual.getRGB(x, y) if (expectedRgb == actualRgb) { continue } differentPixels ++ } } }
  3. PixelPerfect public class Paparazzi @JvmOverloads constructor( ... private val maxPercentDifference:

    Double = detectMaxPercentDifferenceDefault(), ... ) : TestRule { public fun detectMaxPercentDifferenceDefault(): Double = System.getProperty("paparazzi.maxPercentDifferenceDefault") ?: 0.01
  4. fun compare(expected: Image, actual: Image): DiffResult { var differentPixels: Long

    = 0 for (y in 0 until height) { for (x in 0 until width) { val expectedRgb = expected.getRGB(x, y) val actualRgb = actual.getRGB(x, y) if (expectedRgb == actualRgb) { continue } differentPixels ++ } } } OffByTwo
  5. fun compare(expected: Image, actual: Image): DiffResult { var differentPixels: Long

    = 0 for (y in 0 until height) { for (x in 0 until width) { val expectedRgb = expected.getRGB(x, y) val actualRgb = actual.getRGB(x, y) if (expectedRgb == actualRgb) { continue } if (abs(deltaR) <= 2 && abs(deltaG) <= 2 && abs(deltaB) <= 2) { similarPixels ++ continue } differentPixels ++ } } } OffByTwo 🫣
  6. "Instead of continuing to fi ght this, you guys really

    really need to just to record images in CI, so they'll always be identical, and you wouldn't need thresholds at all. Many companies build an custom internal scripts to do this (for instance, Meta, Google, AirBnb, Uber all have custom internal tools)."
  7. "Instead of continuing to fi ght this, you guys really

    really need to just to record images in CI, so they'll always be identical, and you wouldn't need thresholds at all. Many companies build an custom internal scripts to do this (for instance, Meta, Google, AirBnb, Uber all have custom internal tools)." (proceeds with sales pitch)
  8. Mean Structural SIMilarity Index : local means (luminance) μx, μy

    : variances (contrast) σx, σy : covariance (structure) σxy C1, C2 : constants
  9. Mean Structural SIMilarity Index for all (x,y) in window L(x,

    y) => μx, μy, σx, σy, σxy L(x, y) = 0.21R + 0.72G + 0.07B
  10. Mean Structural SIMilarity Index for all (x,y) in window L(x,

    y) => μx, μy, σx, σy, σxy L(x, y) = 0.21R + 0.72G + 0.07B
  11. Mean Structural SIMilarity Index for all (x,y) in window L(x,

    y) => μx, μy, σx, σy, σxy L(x, y) = 0.21R + 0.72G + 0.07B
  12. A perceptual metric that compares two images based on luminance,

    contrast, and structure, mimicking human visual perception. Mean Structural SIMilarity Index
  13. A perceptual metric that compares two images based on luminance,

    contrast, and structure, mimicking human visual perception. ✅ Detects visual degradation without being overly sensitive to minor pixel noise. Mean Structural SIMilarity Index
  14. A perceptual metric that compares two images based on luminance,

    contrast, and structure, mimicking human visual perception. ✅ Detects visual degradation without being overly sensitive to minor pixel noise. Mean Structural SIMilarity Index ✅ Outperforms pixel-perfect diffs when testing dynamic UI (text, gradients, anti-aliasing).
  15. A perceptual metric that compares two images based on luminance,

    contrast, and structure, mimicking human visual perception. ✅ Detects visual degradation without being overly sensitive to minor pixel noise. Mean Structural SIMilarity Index ✅ Outperforms pixel-perfect diffs when testing dynamic UI (text, gradients, anti-aliasing). >= 1.0: Perfect match >= 0.98: Similar, small diff but perceptually identical < 0.98: Signi fi cant structural change
  16. Foveated Image Difference Metric (FLIP) • Mimics foveated vision (sharp

    at center, blurry at edges) • Penalizes artifacts more strongly in central vision • Sensitive to temporal & spatial contrast, banding, and ghosting
  17. Foveated Image Difference Metric (FLIP) • Mimics foveated vision (sharp

    at center, blurry at edges) • Penalizes artifacts more strongly in central vision • Sensitive to temporal & spatial contrast, banding, and ghosting Scale-Invariant Feature Transform (SIFT) • Matches keypoints (high-contrast, information-rich features) • Matches across different resolutions, device densities, illuminations and rotations
  18. Foveated Image Difference Metric (FLIP) • Mimics foveated vision (sharp

    at center, blurry at edges) • Penalizes artifacts more strongly in central vision • Sensitive to temporal & spatial contrast, banding, and ghosting Scale-Invariant Feature Transform (SIFT) • Matches keypoints (high-contrast, information-rich features) • Matches across different resolutions, device densities, illuminations and rotations Delta E (ΔE) = "Perceived Color Difference" • Relies on a perceptually uniform Color Space (CIELAB), designed to model human vision • Strong at color mismatches, but can overreact to anti-aliasing, and text rendering jitter • Good for assets, icons, and precise branding — not always ideal for dynamic UIs
  19. SSIM • https://medium.com/@akp83540/structural-similarity-index-ssim-c5862bb2b520 • https://ece.uwaterloo.ca/~z70wang/publications/ssim.pdf FLIP • https://research.nvidia.com/publication/ fl ip

    • https://research.nvidia.com/sites/default/ fi les/node/3260/FLIP_Paper.pdf • https://github.com/NVlabs/ fl ip SIFT • https://www.cs.ubc.ca/~lowe/papers/iccv99.pdf • https://www.cs.ubc.ca/~lowe/papers/ijcv04.pdf • https://docs.opencv.org/4.x/da/df5/tutorial_py_sift_intro.html DeltaE • https://zschuessler.github.io/DeltaE/learn/#toc-de fi ning-delta-e • CIEDE2000: https://hajim.rochester.edu/ece/sites/gsharma/ciede2000/ciede2000noteCRNA.pdf
  20. private val differ: Differ = System.getProperty("app.cash.paparazzi.differ") ?. lowercase().let { differ

    -> when (differ) { "offbytwo" -> OffByTwo "pixelperfect" -> PixelPerfect "mssim" -> Mssim "sift" -> Sift "flip" -> Flip "de2000" -> DeltaE2000 else -> error("Unknown differ type '$differ'.") } } https://github.com/cashapp/paparazzi/pull/2009
  21. public interface RenderExtension { /** * Allows this extension to

    modify the view hierarchy * represented by [contentView]. * * Returns the root view of the modified hierarchy. */ public fun renderView(contentView: View): View }
  22. public class AccessibilityRenderExtension : RenderExtension { override fun renderView(contentView: View):

    View { return LinearLayout(contentView.context).apply { orientation = LinearLayout.HORIZONTAL ... addView(contentView, ... ) val overlayDetailsView = AccessibilityOverlayDetailsView(context) addView(overlayDetailsView, ... ) } } }
  23. private fun <T : AbstractTestTask> T.setTestReporter( testReporter: TestReporter ) {

    AbstractTestTask :: class.java .getDeclaredMethod("setTestReporter",TestReporter :: class.java) .apply { isAccessible = true invoke(this@setTestReporter, testReporter) } }
  24. // feature/secret/build.gradle plugins { id 'com.android.library' id 'kotlin-android' id 'app.cash.paparazzi'

    } // app/build.gradle plugins { id 'com.android.application' id 'kotlin-android' id 'app.cash.paparazzi' }
  25. // feature/secret/build.gradle plugins { id 'com.android.library' id 'kotlin-android' id 'app.cash.paparazzi'

    } // app/build.gradle plugins { id 'com.android.application' id 'kotlin-android' id 'app.cash.paparazzi' } // build.gradle plugins { id 'binary-compatibility-validator' id 'app.cash.paparazzi' }
  26. ./gradlew generateSnapshotBrowser > Task :generateSnapshotBrowser Generated snapshot browser [1 modules,

    40 methods] at: /Users/john.rodriguez/Development/paparazzi/build/paparazzi/index.html $
  27. ./gradlew generateSnapshotBrowser > Task :generateSnapshotBrowser Generated snapshot browser [1 modules,

    40 methods] at: /Users/john.rodriguez/Development/paparazzi/build/paparazzi/index.html $ feature1 featureN ...
  28. ./gradlew generateSnapshotBrowser > Task :generateSnapshotBrowser Generated snapshot browser [1 modules,

    40 methods] at: /Users/john.rodriguez/Development/paparazzi/build/paparazzi/index.html $ feature1 featureN ... window.runs["12345"] = [ { "name": "loading", "testName": "app.cash.paparazzi.Feature1Test", "timestamp": "2025-03-20T10:27:43.000Z", "tags": { "project": "redesign" " }, "file": "images/1234.png" } window.runs["12345"] = [ { "name": "loading", "testName": "app.cash.paparazzi.Feature1Test", "timestamp": "2025-03-20T10:27:43.000Z", "tags": { "project": "redesign" " }, "file": "images/1234.png" }
  29. ./gradlew generateSnapshotBrowser > Task :generateSnapshotBrowser Generated snapshot browser [1 modules,

    40 methods] at: /Users/john.rodriguez/Development/paparazzi/build/paparazzi/index.html $ feature1 featureN ... window.runs["12345"] = [ { "name": "loading", "testName": "app.cash.paparazzi.Feature1Test", "timestamp": "2025-03-20T10:27:43.000Z", "tags": { "project": "redesign" " }, "file": "images/1234.png" } window.runs["12345"] = [ { "name": "loading", "testName": "app.cash.paparazzi.Feature1Test", "timestamp": "2025-03-20T10:27:43.000Z", "tags": { "project": "redesign" " }, "file": "images/1234.png" } : (root)
  30. Potential Ideas ✅ Comparing snapshots across app versions ✅ Gradle

    DSL for theming ✅ Export and host on internal portal or go/link ✅ Enable plugin for iOS screenshots + AI parity checker
  31. Potential Ideas ✅ Comparing snapshots across app versions ✅ Gradle

    DSL for theming ✅ Export and host on internal portal or go/link ✅ Enable plugin for iOS screenshots + AI parity checker ...propose your ideas on the repo discussion board!
  32. paparazzi.gif( view = container, start = 0L, end = 2_000L,

    keyframes = listOf( // t=200: start loading. Transition finishes t=500. (start + 300). Keyframe(200L) { loadingHelper.isLoading = true }, // t=400: stop loading. Schedule this for t=1200 (start + 1000). Keyframe(400L) { loadingHelper.isLoading = false }, // t=1600: start loading again. Will finish at t=1900. Keyframe(1_600L) { loadingHelper.isLoading = true }, ) }