blob: b6dee94b577f17a3ce34f857b2dfdc10cb7fcbdd [file] [log] [blame]
package platform.test.screenshot.matchers
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Rect
import kotlin.math.max
import kotlin.math.sqrt
import platform.test.screenshot.proto.ScreenshotResultProto
/**
* A matcher designed to replicate a human checking an image to see if there are differences.
*
* [accountForGrouping] When this setting is on, the matcher will be more more lenient on color
* differences of pixels that are not found in a group of differing pixels. This means that a single
* pixel that is not the same as the golden will be given more leniency than a line or circle of
* pixels that are different from the golden.
*
* [accountForTransparency] The matcher will apply a background to any non-opaque pixel so the color
* diffing is only comparing opaque colours. Multiple different backgrounds will be used to ensure
* pixels close to the background color fail if their alpha is significantly different from the
* golden.
*/
class HumanEyeMatcher(
private val accountForGrouping: Boolean = true,
private val accountForTransparency: Boolean = true,
) : BitmapMatcher() {
override fun compareBitmaps(
expected: IntArray,
given: IntArray,
width: Int,
height: Int,
regions: List<Rect>
): MatchResult {
check(expected.size == given.size) {
"Pixels in expected (${expected.size}) does not match pixels in actual (${given.size})"
}
var ignored = 0
// Prepare colorDiffArray
val colorDiffArray =
DoubleArray(width * height) { index ->
val x = index % width
val y = index / width
if (regions.isEmpty() || regions.any { it.containsInclusive(x, y) }) {
if (accountForTransparency) {
colorDiffWithTransparency(expected[index], given[index])
} else {
colorDiff(expected[index], given[index])
}
} else {
ignored++
IGNORED_COLOR_DIFF
}
}
fun isIndexSameForLargeArea(index: Int) = isSameForLargeArea(colorDiffArray[index])
if (!accountForGrouping) {
val diffArray =
IntArray(width * height) { index ->
if (isIndexSameForLargeArea(index)) Color.TRANSPARENT else Color.MAGENTA
}
return createMatchResult(
width,
height,
diffArray.count { diff -> diff == Color.TRANSPARENT } - ignored,
diffArray.count { diff -> diff == Color.MAGENTA },
ignored,
diffArray,
)
}
fun getEasiestThresholdFailed(index: Int): Double? {
val colorDiff = colorDiffArray[index]
return when {
colorDiff == IGNORED_COLOR_DIFF -> null
colorDiff > THRESHOLD_ISOLATED_PIXEL -> THRESHOLD_ISOLATED_PIXEL
colorDiff > THRESHOLD_1PX_LINE_OF_PIXELS -> THRESHOLD_1PX_LINE_OF_PIXELS
colorDiff > THRESHOLD_2PX_LINE_OF_PIXELS -> THRESHOLD_2PX_LINE_OF_PIXELS
colorDiff > THRESHOLD_BLOCK_OF_PIXELS -> THRESHOLD_BLOCK_OF_PIXELS
else -> 0.0
}
}
val diffArray =
IntArray(colorDiffArray.size) { index ->
// Also covers the ignored case
if (isIndexSameForLargeArea(index)) return@IntArray Color.TRANSPARENT
val x = index % width
val y = index / width
val currThreshold = getEasiestThresholdFailed(index)!!
// null = ignored or out of bounds of image
val upThreshold =
if (y > 0) getEasiestThresholdFailed(x + width * (y - 1)) else null
val downThreshold =
if (y < height - 1) getEasiestThresholdFailed(x + width * (y + 1)) else null
val leftThreshold =
if (x > 0) getEasiestThresholdFailed(x - 1 + width * y) else null
val rightThreshold =
if (x < width - 1) getEasiestThresholdFailed(x + 1 + width * y) else null
// Pixels with lower diff thresholds are not counted as neighbouring diffs
var neighbouringDiffs = 4
if (upThreshold != null && currThreshold > upThreshold) neighbouringDiffs--
if (downThreshold != null && currThreshold > downThreshold) neighbouringDiffs--
if (leftThreshold != null && currThreshold > leftThreshold) neighbouringDiffs--
if (rightThreshold != null && currThreshold > rightThreshold) neighbouringDiffs--
if (isSame(colorDiffArray[index], neighbouringDiffs)) {
Color.TRANSPARENT
} else {
Color.MAGENTA
}
}
return createMatchResult(
width,
height,
diffArray.count { diff -> diff == Color.TRANSPARENT } - ignored,
diffArray.count { diff -> diff == Color.MAGENTA },
ignored,
diffArray,
)
}
private fun colorDiffWithTransparency(referenceColor: Int, testColor: Int): Double {
val diffWithWhite =
colorDiff(
blendWithBackground(referenceColor, Color.WHITE),
blendWithBackground(testColor, Color.WHITE)
)
val diffWithBlack =
colorDiff(
blendWithBackground(referenceColor, Color.BLACK),
blendWithBackground(testColor, Color.BLACK)
)
return max(diffWithWhite, diffWithBlack)
}
// ref
// R. F. Witzel, R. W. Burnham, and J. W. Onley. Threshold and suprathreshold perceptual color
// differences. J. Optical Society of America, 63:615{625, 1973. 14
private fun colorDiff(referenceColor: Int, testColor: Int): Double {
val green = Color.green(referenceColor) - Color.green(testColor)
val blue = Color.blue(referenceColor) - Color.blue(testColor)
val red = Color.red(referenceColor) - Color.red(testColor)
val redMean = (Color.red(referenceColor) + Color.red(testColor)) / 2
val (redScalar, blueScalar) = if (redMean < 128) Pair(2, 3) else Pair(3, 2)
val greenScalar = 4
return sqrt(
((redScalar * red * red) + (greenScalar * green * green) + (blueScalar * blue * blue))
.toDouble()
)
}
/**
* This function is more lenient (uses a higher diff threshold) on isolated pixels than it is on
* lines or blocks of differing pixels. This is to emulate the human eye. It is harder to see
* color differences in very small objects as compared to larger ones.
*/
private fun getThreshold(neighbouringDiffs: Int): Double =
when (neighbouringDiffs) {
0,
1 -> THRESHOLD_ISOLATED_PIXEL
2 -> THRESHOLD_1PX_LINE_OF_PIXELS
3 -> THRESHOLD_2PX_LINE_OF_PIXELS
4 -> THRESHOLD_BLOCK_OF_PIXELS
else ->
throw IllegalArgumentException(
"Unsupported neighbouringDiffs value: $neighbouringDiffs"
)
}
private fun isSameForLargeArea(colorDiff: Double) = colorDiff <= THRESHOLD_BLOCK_OF_PIXELS
private fun isSame(colorDiff: Double, neighbouringDiffs: Int) =
colorDiff <= getThreshold(neighbouringDiffs)
/** Any alpha component of the [backgroundColor] will be ignored. */
private fun blendWithBackground(color: Int, backgroundColor: Int): Int {
val alpha: Float = Color.alpha(color) / 255f
if (alpha == 1f) return color
if (alpha == 0f) return backgroundColor
val outRed = alpha * Color.red(color) + (1 - alpha) * Color.red(backgroundColor)
val outGreen = alpha * Color.green(color) + (1 - alpha) * Color.green(backgroundColor)
val outBlue = alpha * Color.blue(color) + (1 - alpha) * Color.blue(backgroundColor)
return Color.valueOf(outRed / 255f, outGreen / 255f, outBlue / 255f).toArgb()
}
private fun createMatchResult(
width: Int,
height: Int,
samePixels: Int,
differentPixels: Int,
ignoredPixels: Int,
diffBitmapArray: IntArray,
): MatchResult {
val stats =
ScreenshotResultProto.DiffResult.ComparisonStatistics.newBuilder()
.setNumberPixelsCompared(width * height)
.setNumberPixelsIdentical(samePixels)
.setNumberPixelsDifferent(differentPixels)
.setNumberPixelsIgnored(ignoredPixels)
.build()
return if (differentPixels > 0) {
val diff = Bitmap.createBitmap(diffBitmapArray, width, height, Bitmap.Config.ARGB_8888)
MatchResult(matches = false, diff = diff, comparisonStatistics = stats)
} else {
MatchResult(matches = true, diff = null, comparisonStatistics = stats)
}
}
private companion object {
const val THRESHOLD_BLOCK_OF_PIXELS = 3.0
const val THRESHOLD_2PX_LINE_OF_PIXELS = 10.0
const val THRESHOLD_1PX_LINE_OF_PIXELS = 12.0
const val THRESHOLD_ISOLATED_PIXEL = 40.0
const val IGNORED_COLOR_DIFF = -1.0
private fun Rect.containsInclusive(x: Int, y: Int) = x in left..right && y in top..bottom
}
}