Skip to content

Commit 7355432

Browse files
authored
Merge pull request #11 from JoelWhitney/jw/deltaE
Add perceptualTolerance parameter to Diffing+bitmap to compare perceptual differences using Delta E 1994
2 parents 789307e + 4e29008 commit 7355432

File tree

3 files changed

+126
-17
lines changed

3 files changed

+126
-17
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
/build
2-
/.gradle
2+
/.gradle
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,83 @@
11
package com.quickbird.snapshot
22

3+
import android.graphics.Color as AndroidColor
34
import androidx.annotation.ColorInt
5+
import kotlin.collections.component1
6+
import kotlin.math.abs
7+
import kotlin.math.cbrt
8+
import kotlin.math.min
9+
import kotlin.math.pow
10+
import kotlin.math.sqrt
411

512
data class Color(@ColorInt val value: Int)
613

714
val @receiver:ColorInt Int.color
815
get() = Color(this)
16+
17+
/**
18+
* Calculates [Delta E (1994)](http://zschuessler.github.io/DeltaE/learn/#toc-delta-e-94) between
19+
* 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)
20+
*/
21+
fun Color.deltaE(other: Color): Double {
22+
if (this == other) {
23+
return 0.0
24+
}
25+
// Delta E (1994) is in a 0-100 scale, so we need to divide by 100 to transform it to a percentage
26+
//
27+
return min(this.deltaE1994(other) / 100, 1.0)
28+
}
29+
30+
/**
31+
* Convert the color to the CIE XYZ color space within nominal range of [0.0, 1.0]
32+
* using sRGB color space and D65 white reference white
33+
*/
34+
/**
35+
* Convert the color to the CIE LAB color space using sRGB color space and D65 white reference white
36+
*/
37+
private fun Color.toLAB(): FloatArray {
38+
val labConnector = ColorSpace.connect(
39+
ColorSpace.get(ColorSpace.Named.SRGB),
40+
ColorSpace.get(ColorSpace.Named.CIE_LAB)
41+
)
42+
43+
val rgb = floatArrayOf(
44+
AndroidColor.red(value) / 255.0f,
45+
AndroidColor.green(value) / 255.0f,
46+
AndroidColor.blue(value) / 255.0f
47+
)
48+
49+
return labConnector.transform(rgb[0], rgb[1], rgb[2])
50+
}
51+
52+
/**
53+
* Calculates [Delta E (1994)](http://zschuessler.github.io/DeltaE/learn/#toc-delta-e-94) between
54+
* two colors in the CIE LAB color space returning a value between 0-100 (0 means no difference, 100 means completely opposite)
55+
*/
56+
private fun Color.deltaE1994(other: Color): Double {
57+
val (l1, a1, b1) = this.toLAB()
58+
val (l2, a2, b2) = other.toLAB()
59+
60+
val deltaL = l1 - l2
61+
val c1 = sqrt(a1.pow(2) + b1.pow(2))
62+
val c2 = sqrt(a2.pow(2) + b2.pow(2))
63+
val deltaC = c1 - c2
64+
val deltaA = a1 - a2
65+
val deltaB = b1 - b2
66+
val deltaH = sqrt(abs(deltaA.pow(2) + deltaB.pow(2) - deltaC.pow(2)))
67+
68+
val sl = 1
69+
val kl = 1
70+
val kc = 1
71+
val kh = 1
72+
val k1 = 0.045
73+
val k2 = 0.015
74+
75+
val sc = 1 + k1 * c1
76+
val sh = 1 + k2 * c1
77+
78+
return sqrt(
79+
(deltaL / (kl * sl)).pow(2) +
80+
(deltaC / (kc * sc)).pow(2) +
81+
(deltaH / (kh * sh)).pow(2)
82+
)
83+
}
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
11
package com.quickbird.snapshot
22

33
import android.graphics.Bitmap
4+
import android.util.Log
45
import android.graphics.Color as AndroidColor
56

7+
private var maximumDeltaE: Double? = null
8+
9+
/**
10+
* A Bitmap comparison diffing strategy for comparing images based on pixel equality.
11+
*
12+
* @param colorDiffing A function that compares two colors and returns a color representing the difference.
13+
* @param tolerance Total percentage of pixels that must match between image. The default value of 0.0% means all pixels must match
14+
* @param perceptualTolerance Percentage each pixel can be different from source pixel and still considered
15+
* a match. The default value of 0.0% means pixels must match perfectly whereas the recommended value of 0.02% mimics the
16+
* [precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the human eye.
17+
*/
618
fun Diffing.Companion.bitmap(
719
colorDiffing: Diffing<Color>,
8-
tolerance: Double = 0.0
20+
tolerance: Double = 0.0,
21+
perceptualTolerance: Double = 0.0
922
) = Diffing<Bitmap> { first, second ->
10-
val difference = first differenceTo second
11-
12-
if (difference <= tolerance) null
13-
else first.copy(first.config, true).apply {
14-
updatePixels { x, y, color ->
15-
if (x < second.width && y < second.height)
16-
colorDiffing(color, second.getPixel(x, y).color) ?: color
17-
else color
23+
val difference = first.differenceTo(second, perceptualTolerance)
24+
25+
if (difference <= tolerance) {
26+
Log.d("SnapshotDiffing", "Actual image difference ${difference.toBigDecimal().toPlainString()}, required image difference ${tolerance.toBigDecimal().toPlainString()}")
27+
null
28+
} else {
29+
var log = "Actual image difference ${difference.toBigDecimal().toPlainString()} is greater than max allowed ${tolerance.toBigDecimal().toPlainString()}"
30+
maximumDeltaE?.let { log += ", Actual perceptual difference ${it.toBigDecimal().toPlainString()} is greater than max allowed ${perceptualTolerance.toBigDecimal().toPlainString()}" }
31+
Log.e("SnapshotDiffing", log)
32+
33+
first.config.let {
34+
first.copy(it, true).apply {
35+
updatePixels { x, y, color ->
36+
if (x < second.width && y < second.height)
37+
colorDiffing(color, second.getPixel(x, y).color) ?: color
38+
else color
39+
}
40+
}
1841
}
1942
}
2043
}
@@ -36,14 +59,25 @@ val Diffing.Companion.intMean
3659
else first / 2 + second / 2
3760
}
3861

39-
private infix fun Bitmap.differenceTo(other: Bitmap): Double {
62+
private fun Bitmap.differenceTo(other: Bitmap, perceptualTolerance: Double): Double {
4063
val thisPixels = this.pixels
4164
val otherPixels = other.pixels
42-
if (thisPixels.size != otherPixels.size) return 100.0
65+
if (thisPixels.size != otherPixels.size) return 1.0
4366

44-
val differentPixelCount = thisPixels
45-
.zip(otherPixels, Color::equals)
46-
.count { !it }
67+
// Perceptually compare if the tolerance is greater than 0.0
68+
//
69+
val pixelDifferenceCount = if (perceptualTolerance > 0.0) {
70+
val deltaEPixels = thisPixels
71+
.zip(otherPixels, Color::deltaE)
72+
// Find the maximum delta E value for logging purposes
73+
//
74+
maximumDeltaE = deltaEPixels.maxOrNull() ?: 0.0
75+
deltaEPixels.count { it > (perceptualTolerance) }
76+
} else {
77+
thisPixels
78+
.zip(otherPixels, Color::equals)
79+
.count { !it }
80+
}
4781

48-
return differentPixelCount.toDouble() / thisPixels.size
49-
}
82+
return pixelDifferenceCount.toDouble() / thisPixels.size
83+
}

0 commit comments

Comments
 (0)