Skip to content

Commit

Permalink
Merge pull request #11 from JoelWhitney/jw/deltaE
Browse files Browse the repository at this point in the history
Add perceptualTolerance parameter to Diffing+bitmap to compare perceptual differences using Delta E 1994
  • Loading branch information
eldrig0 authored Nov 15, 2024
2 parents 789307e + 4e29008 commit 7355432
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 17 deletions.
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
}

0 comments on commit 7355432

Please sign in to comment.