diff --git a/.gitignore b/.gitignore index c8c70f2..00fd4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /build -/.gradle +/.gradle \ No newline at end of file diff --git a/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Color.kt b/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Color.kt index cc5e2dd..a581517 100644 --- a/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Color.kt +++ b/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Color.kt @@ -1,8 +1,83 @@ package com.quickbird.snapshot +import android.graphics.Color as AndroidColor import androidx.annotation.ColorInt +import kotlin.collections.component1 +import kotlin.math.abs +import kotlin.math.cbrt +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt data class Color(@ColorInt val value: Int) val @receiver:ColorInt Int.color get() = Color(this) + +/** + * Calculates [Delta E (1994)](http://zschuessler.github.io/DeltaE/learn/#toc-delta-e-94) between + * two colors in the CIE LAB color space returning a value between 0.0 - 1.0 (0.0 means no difference, 1.0 means completely opposite) + */ +fun Color.deltaE(other: Color): Double { + if (this == other) { + return 0.0 + } + // Delta E (1994) is in a 0-100 scale, so we need to divide by 100 to transform it to a percentage + // + return min(this.deltaE1994(other) / 100, 1.0) +} + +/** + * Convert the color to the CIE XYZ color space within nominal range of [0.0, 1.0] + * using sRGB color space and D65 white reference white + */ +/** + * Convert the color to the CIE LAB color space using sRGB color space and D65 white reference white + */ +private fun Color.toLAB(): FloatArray { + val labConnector = ColorSpace.connect( + ColorSpace.get(ColorSpace.Named.SRGB), + ColorSpace.get(ColorSpace.Named.CIE_LAB) + ) + + val rgb = floatArrayOf( + AndroidColor.red(value) / 255.0f, + AndroidColor.green(value) / 255.0f, + AndroidColor.blue(value) / 255.0f + ) + + return labConnector.transform(rgb[0], rgb[1], rgb[2]) +} + +/** + * Calculates [Delta E (1994)](http://zschuessler.github.io/DeltaE/learn/#toc-delta-e-94) between + * two colors in the CIE LAB color space returning a value between 0-100 (0 means no difference, 100 means completely opposite) + */ +private fun Color.deltaE1994(other: Color): Double { + val (l1, a1, b1) = this.toLAB() + val (l2, a2, b2) = other.toLAB() + + val deltaL = l1 - l2 + val c1 = sqrt(a1.pow(2) + b1.pow(2)) + val c2 = sqrt(a2.pow(2) + b2.pow(2)) + val deltaC = c1 - c2 + val deltaA = a1 - a2 + val deltaB = b1 - b2 + val deltaH = sqrt(abs(deltaA.pow(2) + deltaB.pow(2) - deltaC.pow(2))) + + val sl = 1 + val kl = 1 + val kc = 1 + val kh = 1 + val k1 = 0.045 + val k2 = 0.015 + + val sc = 1 + k1 * c1 + val sh = 1 + k2 * c1 + + return sqrt( + (deltaL / (kl * sl)).pow(2) + + (deltaC / (kc * sc)).pow(2) + + (deltaH / (kh * sh)).pow(2) + ) +} \ No newline at end of file diff --git a/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Diffing+bitmap.kt b/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Diffing+bitmap.kt index a38c1b4..f72b5c4 100644 --- a/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Diffing+bitmap.kt +++ b/snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Diffing+bitmap.kt @@ -1,20 +1,43 @@ package com.quickbird.snapshot import android.graphics.Bitmap +import android.util.Log import android.graphics.Color as AndroidColor +private var maximumDeltaE: Double? = null + +/** + * A Bitmap comparison diffing strategy for comparing images based on pixel equality. + * + * @param colorDiffing A function that compares two colors and returns a color representing the difference. + * @param tolerance Total percentage of pixels that must match between image. The default value of 0.0% means all pixels must match + * @param perceptualTolerance Percentage each pixel can be different from source pixel and still considered + * a match. The default value of 0.0% means pixels must match perfectly whereas the recommended value of 0.02% mimics the + * [precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the human eye. + */ fun Diffing.Companion.bitmap( colorDiffing: Diffing, - tolerance: Double = 0.0 + tolerance: Double = 0.0, + perceptualTolerance: Double = 0.0 ) = Diffing { first, second -> - val difference = first differenceTo second - - if (difference <= tolerance) null - else first.copy(first.config, true).apply { - updatePixels { x, y, color -> - if (x < second.width && y < second.height) - colorDiffing(color, second.getPixel(x, y).color) ?: color - else color + val difference = first.differenceTo(second, perceptualTolerance) + + if (difference <= tolerance) { + Log.d("SnapshotDiffing", "Actual image difference ${difference.toBigDecimal().toPlainString()}, required image difference ${tolerance.toBigDecimal().toPlainString()}") + null + } else { + var log = "Actual image difference ${difference.toBigDecimal().toPlainString()} is greater than max allowed ${tolerance.toBigDecimal().toPlainString()}" + maximumDeltaE?.let { log += ", Actual perceptual difference ${it.toBigDecimal().toPlainString()} is greater than max allowed ${perceptualTolerance.toBigDecimal().toPlainString()}" } + Log.e("SnapshotDiffing", log) + + first.config.let { + first.copy(it, true).apply { + updatePixels { x, y, color -> + if (x < second.width && y < second.height) + colorDiffing(color, second.getPixel(x, y).color) ?: color + else color + } + } } } } @@ -36,14 +59,25 @@ val Diffing.Companion.intMean else first / 2 + second / 2 } -private infix fun Bitmap.differenceTo(other: Bitmap): Double { +private fun Bitmap.differenceTo(other: Bitmap, perceptualTolerance: Double): Double { val thisPixels = this.pixels val otherPixels = other.pixels - if (thisPixels.size != otherPixels.size) return 100.0 + if (thisPixels.size != otherPixels.size) return 1.0 - val differentPixelCount = thisPixels - .zip(otherPixels, Color::equals) - .count { !it } + // Perceptually compare if the tolerance is greater than 0.0 + // + val pixelDifferenceCount = if (perceptualTolerance > 0.0) { + val deltaEPixels = thisPixels + .zip(otherPixels, Color::deltaE) + // Find the maximum delta E value for logging purposes + // + maximumDeltaE = deltaEPixels.maxOrNull() ?: 0.0 + deltaEPixels.count { it > (perceptualTolerance) } + } else { + thisPixels + .zip(otherPixels, Color::equals) + .count { !it } + } - return differentPixelCount.toDouble() / thisPixels.size -} + return pixelDifferenceCount.toDouble() / thisPixels.size +} \ No newline at end of file