Skip to content

Add perceptualTolerance parameter to Diffing+bitmap to compare perceptual differences using Delta E 1994 #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/build
/.gradle
/.gradle
75 changes: 75 additions & 0 deletions snapshot/src/androidMain/kotlin/com/quickbird/snapshot/Color.kt
Original file line number Diff line number Diff line change
@@ -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)
)
}
Original file line number Diff line number Diff line change
@@ -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<Color>,
tolerance: Double = 0.0
tolerance: Double = 0.0,
perceptualTolerance: Double = 0.0
) = Diffing<Bitmap> { 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
}
}
}
}
}
Expand All @@ -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
}