blob: eabb43b8197cab6e854d01456d4665da828b546d [file] [log] [blame]
/*
* Copyright 2022 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 androidx.testutils.paparazzi
import androidx.testutils.paparazzi.ImageDiffer.DiffResult.Similar
import java.awt.image.BufferedImage
import kotlin.math.pow
@JvmDefaultWithCompatibility
/**
* Functional interface to compare two images and returns a [ImageDiffer.DiffResult] ADT containing
* comparison statistics and a difference image, if applicable.
*/
fun interface ImageDiffer {
/**
* Compare image [a] to image [b]. Implementations may assume [a] and [b] have the same
* dimensions.
*/
fun diff(a: BufferedImage, b: BufferedImage): DiffResult
/** A name to be used in logs for this differ, defaulting to the class's simple name. */
val name
get() = requireNotNull(this::class.simpleName) {
"Could not determine ImageDiffer.name reflectively. Please override ImageDiffer.name."
}
/**
* Result ADT returned from [diff].
*
* A differ may permit a small amount of difference, even for [Similar] results. Similar results
* must include a [description], even if it's trivial, but may omit the [highlights] image if
* it would be fully transparent.
*
* @property description A human-readable description of how the images differed, such as the
* count of different pixels or percentage changed. Displayed in test failure messages and in
* CI.
*
* @property highlights An image with a transparent background, highlighting where the compared
* images differ, typically in shades of magenta. Displayed in CI.
*/
sealed interface DiffResult {
val description: String
val highlights: BufferedImage?
data class Similar(
override val description: String,
override val highlights: BufferedImage? = null
) : DiffResult
data class Different(
override val description: String,
override val highlights: BufferedImage
) : DiffResult
}
/**
* Pixel perfect image differ requiring images to be identical.
*
* The alpha channel is treated as pre-multiplied, meaning RGB channels may differ if the alpha
* channel is 0 (fully transparent).
*/
// TODO(b/244752233): Support wide gamut images.
object PixelPerfect : ImageDiffer {
override fun diff(a: BufferedImage, b: BufferedImage): DiffResult {
check(a.width == b.width && a.height == b.height) { "Images are different sizes" }
val width = a.width
val height = b.height
val highlights = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
var count = 0
for (x in 0 until width) {
for (y in 0 until height) {
val aPixel = a.getRGB(x, y)
val bPixel = b.getRGB(x, y)
// Compare full ARGB pixels, but allow other channels to differ if alpha is 0
if (aPixel == bPixel || (aPixel ushr 24 == 0 && bPixel ushr 24 == 0)) {
highlights.setRGB(x, y, TRANSPARENT.toInt())
} else {
count++
highlights.setRGB(x, y, MAGENTA.toInt())
}
}
}
val description = "$count of ${width * height} pixels different"
return if (count > 0) {
DiffResult.Different(description, highlights)
} else {
DiffResult.Similar(description)
}
}
}
/**
* Image comparison using Structural Similarity Index, developed by Wang, Bovik, Sheikh, and
* Simoncelli. Details can be read in their paper:
* https://ece.uwaterloo.ca/~z70wang/publications/ssim.pdf
*/
object MSSIMMatcher : ImageDiffer {
override fun diff(a: BufferedImage, b: BufferedImage): DiffResult {
val aIntArray = a.toIntArray()
val bIntArray = b.toIntArray()
val SSIMTotal = calculateSSIM(aIntArray, bIntArray, a.width, a.height)
val stats = "[MSSIM] Required SSIM: $SSIM_THRESHOLD, Actual " +
"SSIM: " + "%.3f".format(SSIMTotal)
if (SSIMTotal >= SSIM_THRESHOLD) {
return DiffResult.Similar(stats)
}
return PixelPerfect.diff(a, b)
}
internal fun calculateSSIM(
ideal: IntArray,
given: IntArray,
width: Int,
height: Int
): Double {
return calculateSSIM(ideal, given, 0, width, width, height)
}
private fun calculateSSIM(
ideal: IntArray,
given: IntArray,
offset: Int,
stride: Int,
width: Int,
height: Int
): Double {
var SSIMTotal = 0.0
var windows = 0
var currentWindowY = 0
while (currentWindowY < height) {
val windowHeight = computeWindowSize(currentWindowY, height)
var currentWindowX = 0
while (currentWindowX < width) {
val windowWidth = computeWindowSize(currentWindowX, width)
val start: Int =
indexFromXAndY(currentWindowX, currentWindowY, stride, offset)
if (isWindowWhite(ideal, start, stride, windowWidth, windowHeight) &&
isWindowWhite(given, start, stride, windowWidth, windowHeight)
) {
currentWindowX += WINDOW_SIZE
continue
}
windows++
val means =
getMeans(ideal, given, start, stride, windowWidth, windowHeight)
val meanX = means[0]
val meanY = means[1]
val variances = getVariances(
ideal, given, meanX, meanY, start, stride,
windowWidth, windowHeight
)
val varX = variances[0]
val varY = variances[1]
val stdBoth = variances[2]
val SSIM = SSIM(meanX, meanY, varX, varY, stdBoth)
SSIMTotal += SSIM
currentWindowX += WINDOW_SIZE
}
currentWindowY += WINDOW_SIZE
}
if (windows == 0) {
return 1.0
}
return SSIMTotal / windows.toDouble()
}
/**
* Compute the size of the window. The window defaults to WINDOW_SIZE, but
* must be contained within dimension.
*/
private fun computeWindowSize(coordinateStart: Int, dimension: Int): Int {
return if (coordinateStart + WINDOW_SIZE <= dimension) {
WINDOW_SIZE
} else {
dimension - coordinateStart
}
}
private fun isWindowWhite(
colors: IntArray,
start: Int,
stride: Int,
windowWidth: Int,
windowHeight: Int
): Boolean {
for (y in 0 until windowHeight) {
for (x in 0 until windowWidth) {
if (colors[indexFromXAndY(x, y, stride, start)] != WHITE) {
return false
}
}
}
return true
}
/**
* This calculates the position in an array that would represent a bitmap given the parameters.
*/
private fun indexFromXAndY(x: Int, y: Int, stride: Int, offset: Int): Int {
return x + y * stride + offset
}
private fun SSIM(
muX: Double,
muY: Double,
sigX: Double,
sigY: Double,
sigXY: Double
): Double {
var SSIM = (2 * muX * muY + CONSTANT_C1) * (2 * sigXY + CONSTANT_C2)
val denom = ((muX * muX + muY * muY + CONSTANT_C1) * (sigX + sigY + CONSTANT_C2))
SSIM /= denom
return SSIM
}
/**
* This method will find the mean of a window in both sets of pixels. The return is an array
* where the first double is the mean of the first set and the second double is the mean of the
* second set.
*/
private fun getMeans(
pixels0: IntArray,
pixels1: IntArray,
start: Int,
stride: Int,
windowWidth: Int,
windowHeight: Int
): DoubleArray {
var avg0 = 0.0
var avg1 = 0.0
for (y in 0 until windowHeight) {
for (x in 0 until windowWidth) {
val index: Int = indexFromXAndY(x, y, stride, start)
avg0 += getIntensity(pixels0[index])
avg1 += getIntensity(pixels1[index])
}
}
avg0 /= windowWidth * windowHeight.toDouble()
avg1 /= windowWidth * windowHeight.toDouble()
return doubleArrayOf(avg0, avg1)
}
/**
* Finds the variance of the two sets of pixels, as well as the covariance of the windows. The
* return value is an array of doubles, the first is the variance of the first set of pixels,
* the second is the variance of the second set of pixels, and the third is the covariance.
*/
private fun getVariances(
pixels0: IntArray,
pixels1: IntArray,
mean0: Double,
mean1: Double,
start: Int,
stride: Int,
windowWidth: Int,
windowHeight: Int
): DoubleArray {
var var0 = 0.0
var var1 = 0.0
var varBoth = 0.0
for (y in 0 until windowHeight) {
for (x in 0 until windowWidth) {
val index: Int = indexFromXAndY(x, y, stride, start)
val v0 = getIntensity(pixels0[index]) - mean0
val v1 = getIntensity(pixels1[index]) - mean1
var0 += v0 * v0
var1 += v1 * v1
varBoth += v0 * v1
}
}
var0 /= windowWidth * windowHeight - 1.toDouble()
var1 /= windowWidth * windowHeight - 1.toDouble()
varBoth /= windowWidth * windowHeight - 1.toDouble()
return doubleArrayOf(var0, var1, varBoth)
}
/**
* Gets the intensity of a given pixel in RGB using luminosity formula
*
* l = 0.21R' + 0.72G' + 0.07B'
*
* The prime symbols dictate a gamma correction of 1.
*/
private fun getIntensity(pixel: Int): Double {
val gamma = 1.0
var l = 0.0
l += 0.21f * (red(pixel) / 255f.toDouble()).pow(gamma)
l += 0.72f * (green(pixel) / 255f.toDouble()).pow(gamma)
l += 0.07f * (blue(pixel) / 255f.toDouble()).pow(gamma)
return l
}
/**
* Return the red component of a color int. This is the same as saying
* (color >> 16) & 0xFF
*/
fun red(color: Int): Int {
return color shr 16 and 0xFF
}
/**
* Return the green component of a color int. This is the same as saying
* (color >> 8) & 0xFF
*/
fun green(color: Int): Int {
return color shr 8 and 0xFF
}
/**
* Return the blue component of a color int. This is the same as saying
* color & 0xFF
*/
fun blue(color: Int): Int {
return color and 0xFF
}
private fun BufferedImage.toIntArray(): IntArray {
val bitmapArray = IntArray(width * height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmapArray[y * width + x] = getRGB(x, y)
}
}
return bitmapArray
}
}
private companion object {
const val MAGENTA = 0xFF_FF_00_FFu
const val TRANSPARENT = 0x00_FF_FF_FFu
const val WHITE = 0xFFFFFF
// These values were taken from the publication
private const val CONSTANT_L = 254.0
private const val CONSTANT_K1 = 0.00001
private const val CONSTANT_K2 = 0.00003
private val CONSTANT_C1 = (CONSTANT_L * CONSTANT_K1).pow(2.0)
private val CONSTANT_C2 = (CONSTANT_L * CONSTANT_K2).pow(2.0)
private const val WINDOW_SIZE = 10
private val SSIM_THRESHOLD: Double = 0.98
}
}