Kotlin / compare-hex-colors.kt 💾

This is a 10,569-byte text/plain file, placed in the public domain, which has been consulted 115 times since the year 2026.

// This function written in Kotlin is not affiliated with the CIE (International Commission on Illumination),
// and is released into the public domain. It is provided "as is" without any warranty, express or implied.

import kotlin.math.*

// The classic CIE ΔE2000 implementation, which operates on two L*a*b* colors, and returns their difference.
// "l" ranges from 0 to 100, while "a" and "b" are unbounded and commonly clamped to the range of -128 to 127.
fun ciede_2000(l_1: Double, a_1: Double, b_1: Double, l_2: Double, a_2: Double, b_2: Double): Double {
	// Working in Kotlin with the CIEDE2000 color-difference formula.
	// k_l, k_c, k_h are parametric factors to be adjusted according to
	// different viewing parameters such as textures, backgrounds...
	val k_l = 1.0;
	val k_c = 1.0;
	val k_h = 1.0;
	var n = (sqrt(a_1 * a_1 + b_1 * b_1) + sqrt(a_2 * a_2 + b_2 * b_2)) * 0.5;
	n = n * n * n * n * n * n * n;
	// A factor involving chroma raised to the power of 7 designed to make
	// the influence of chroma on the total color difference more accurate.
	n = 1.0 + 0.5 * (1.0 - sqrt(n / (n + 6103515625.0)));
	// Application of the chroma correction factor.
	val c_1 = sqrt(a_1 * a_1 * n * n + b_1 * b_1);
	val c_2 = sqrt(a_2 * a_2 * n * n + b_2 * b_2);
	// atan2 is preferred over atan because it accurately computes the angle of
	// a point (x, y) in all quadrants, handling the signs of both coordinates.
	var h_1 = atan2(b_1, a_1 * n);
	var h_2 = atan2(b_2, a_2 * n);
	if (h_1 < 0.0)
		h_1 += 2.0 * PI;
	if (h_2 < 0.0)
		h_2 += 2.0 * PI;
	n = abs(h_2 - h_1);
	// Cross-implementation consistent rounding.
	if (PI - 1E-14 < n && n < PI + 1E-14)
		n = PI;
	// When the hue angles lie in different quadrants, the straightforward
	// average can produce a mean that incorrectly suggests a hue angle in
	// the wrong quadrant, the next lines handle this issue.
	var h_m = (h_1 + h_2) * 0.5;
	var h_d = (h_2 - h_1) * 0.5;
	if (PI < n) {
		h_d += PI;
		// 📜 Sharma’s formulation doesn’t use the next line, but the one after it,
		// and these two variants differ by ±0.0003 on the final color differences.
		h_m += PI;
		// if (h_m < PI) h_m += PI; else h_m -= PI;
	}
	val p = 36.0 * h_m - 55.0 * PI;
	n = (c_1 + c_2) * 0.5;
	n = n * n * n * n * n * n * n;
	// The hue rotation correction term is designed to account for the
	// non-linear behavior of hue differences in the blue region.
	val r_t = -2.0 * sqrt(n / (n + 6103515625.0)) * sin(PI / 3.0 * exp((p * p) / (-25.0 * PI * PI)));
	n = (l_1 + l_2) * 0.5;
	n = (n - 50.0) * (n - 50.0);
	// Lightness.
	val l = (l_2 - l_1) / (k_l * (1.0 + 0.015 * n / sqrt(20.0 + n)));
	// These coefficients adjust the impact of different harmonic
	// components on the hue difference calculation.
	val t = 1.0 +	0.24 * sin(2.0 * h_m + PI * 0.5) +
			0.32 * sin(3.0 * h_m + 8.0 * PI / 15.0) -
			0.17 * sin(h_m + PI / 3.0) -
			0.20 * sin(4.0 * h_m + 3.0 * PI / 20.0);
	n = c_1 + c_2;
	// Hue.
	val h = 2.0 * sqrt(c_1 * c_2) * sin(h_d) / (k_h * (1.0 + 0.0075 * n * t));
	// Chroma.
	val c = (c_2 - c_1) / (k_c * (1.0 + 0.0225 * n));
	// Returning the square root ensures that dE00 accurately reflects the
	// geometric distance in color space, which can range from 0 to around 185.
	return sqrt(l * l + h * h + c * c + c * h * r_t);
}

// GitHub Project : https://github.com/michel-leonard/ciede2000-color-matching
//   Online Tests : https://michel-leonard.github.io/ciede2000-color-matching

// L1 = 24.8   a1 = 11.5   b1 = 3.6
// L2 = 25.8   a2 = 17.6   b2 = -5.0
// CIE ΔE00 = 7.5353340235 (Bruce Lindbloom, Netflix’s VMAF, ...)
// CIE ΔE00 = 7.5353193430 (Gaurav Sharma, OpenJDK, ...)
// Deviation between implementations ≈ 1.5e-5

// See the source code comments for easy switching between these two widely used ΔE*00 implementation variants.

// These color conversion functions written in Kotlin are released into the public domain.
// They are provided "as is" without any warranty, express or implied.

// rgb in 0..1
fun rgb_to_xyz(ra: Double, ga: Double, ba: Double): DoubleArray {
	// Apply a gamma correction to each channel.
	val r = if (ra < 0.040448236277105097) ra / 12.92 else Math.pow((ra + 0.055) / 1.055, 2.4)
	val g = if (ga < 0.040448236277105097) ga / 12.92 else Math.pow((ga + 0.055) / 1.055, 2.4)
	val b = if (ba < 0.040448236277105097) ba / 12.92 else Math.pow((ba + 0.055) / 1.055, 2.4)

	// Applying linear transformation using RGB to XYZ transformation matrix.
	val x = r * 41.24564390896921145 + g * 35.75760776439090507 + b * 18.04374830853290341
	val y = r * 21.26728514056222474 + g * 71.51521552878181013 + b * 7.21749933075596513
	val z = r * 1.93338955823293176 + g * 11.91919550818385936 + b * 95.03040770337479886

	return doubleArrayOf(x, y, z)
}

fun xyz_to_lab(xa: Double, ya: Double, za: Double): DoubleArray {
	// Reference white point : D65 2° Standard observer
	val refX = 95.047
	val refY = 100.000
	val refZ = 108.883

	var x = xa / refX
	var y = ya / refY
	var z = za / refZ

	// Applying the CIE standard transformation.
	x = if (x < 216.0 / 24389.0) ((841.0 / 108.0) * x) + (4.0 / 29.0) else Math.cbrt(x)
	y = if (y < 216.0 / 24389.0) ((841.0 / 108.0) * y) + (4.0 / 29.0) else Math.cbrt(y)
	z = if (z < 216.0 / 24389.0) ((841.0 / 108.0) * z) + (4.0 / 29.0) else Math.cbrt(z)

	val l = (116.0 * y) - 16.0
	val a = 500.0 * (x - y)
	val b = 200.0 * (y - z)

	return doubleArrayOf(l, a, b)
}

// rgb in 0..1
fun rgb_to_lab(r: Double, g: Double, b: Double): DoubleArray {
	val xyz = rgb_to_xyz(r, g, b)
	return xyz_to_lab(xyz[0], xyz[1], xyz[2])
}

fun lab_to_xyz(l: Double, a: Double, b: Double): DoubleArray {
	// Reference white point : D65 2° Standard observer
	val refX = 95.047
	val refY = 100.000
	val refZ = 108.883

	var y = (l + 16.0) / 116.0
	var x = a / 500.0 + y
	var z = y - b / 200.0

	val x3 = x * x * x
	val z3 = z * z * z

	x = if (x3 < 216.0 / 24389.0) (x - 4.0 / 29.0) / (841.0 / 108.0) else x3
	y = if (l < 8.0) l / (24389.0 / 27.0) else y * y * y
	z = if (z3 < 216.0 / 24389.0) (z - 4.0 / 29.0) / (841.0 / 108.0) else z3

	return doubleArrayOf(x * refX, y * refY, z * refZ)
}

// rgb in 0..1
fun xyz_to_rgb(x: Double, y: Double, z: Double): DoubleArray {
	// Applying linear transformation using the XYZ to RGB transformation matrix.
	var r = x * 0.032404541621141049051 + y * -0.015371385127977165753 + z * -0.004985314095560160079
	var g = x * -0.009692660305051867686 + y * 0.018760108454466942288 + z * 0.00041556017530349983
	var b = x * 0.000556434309591145522 + y * -0.002040259135167538416 + z * 0.010572251882231790398

	// Apply gamma correction.
	r = if (r < 0.003130668442500634) 12.92 * r else 1.055 * Math.pow(r, 1.0 / 2.4) - 0.055
	g = if (g < 0.003130668442500634) 12.92 * g else 1.055 * Math.pow(g, 1.0 / 2.4) - 0.055
	b = if (b < 0.003130668442500634) 12.92 * b else 1.055 * Math.pow(b, 1.0 / 2.4) - 0.055

	return doubleArrayOf(r, g, b)
}

// rgb in 0..1
fun lab_to_rgb(l: Double, a: Double, b: Double): DoubleArray {
	val xyz = lab_to_xyz(l, a, b)
	return xyz_to_rgb(xyz[0], xyz[1], xyz[2])
}

// rgb in 0..255
fun hex_to_rgb(s: String): IntArray {
	// Also support the short syntax (ie "#FFF") as input.
	val n = (if (s.length == 4) "#" + s[1].toString().repeat(2) + s[2].toString().repeat(2) + s[3].toString()
		.repeat(2) else s).substring(1).toInt(16)
	return intArrayOf((n shr 16) and 0xFF, (n shr 8) and 0xFF, n and 0xFF)
}

// rgb in 0..255
fun rgb_to_hex(r: Int, g: Int, b: Int): String {
	// Also provide the short syntax (ie "#FFF") as output.
	val s = "#%02X%02X%02X".format(r, g, b)
	return if (s[1] == s[2] && s[3] == s[4] && s[5] == s[6]) "#${s[1]}${s[3]}${s[5]}" else s
}

// Constants used in Color Conversion :
// 216.0 / 24389.0 = 0.0088564516790356308171716757554635286399606379925376194185903481077
// 841.0 / 108.0 = 7.78703703703703703703703703703703703703703703703703703703703703703703703703
// 4.0 / 29.0 = 0.13793103448275862068965517241379310344827586206896551724137931034482758620
// 24389.0 / 27.0 = 903.296296296296296296296296296296296296296296296296296296296296296296296296
// 1.0 / 2.4 = 0.41666666666666666666666666666666666666666666666666666666666666666666666666
// To get 0.040448236277105097132567243294938 we perform x/12.92 = ((x+0.055)/1.055)^2.4
// To get 0.00313066844250063403284123841596 we perform 12.92*x = 1.055*x^(1/2.4)-0.055

//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
////////////////////                                   ///////////////////////
////////////////////              TESTING              ///////////////////////
////////////////////                                   ///////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////

// The goal of this demo in Kotlin is to use the CIEDE2000 function to compare two hexadecimal colors.

fun main() {
	// 1. Define two HEX color codes
	val hex_1 = "#00ff00"   // Pure green
	val hex_2 = "#00c800"   // Slightly darker green

	// 2. Convert HEX colors to RGB format
	val rgb_1 = hex_to_rgb(hex_1)
	val rgb_2 = hex_to_rgb(hex_2)

	// 3. Normalize RGB values (0–255 → 0.0–1.0) and convert them to L*a*b* color space
	val lab_1 = rgb_to_lab(rgb_1[0] / 255.0, rgb_1[1] / 255.0, rgb_1[2] / 255.0)
	val lab_2 = rgb_to_lab(rgb_2[0] / 255.0, rgb_2[1] / 255.0, rgb_2[2] / 255.0)

	// 4. Calculate the color difference using the CIEDE2000 ΔE formula
	val delta_e = ciede_2000(lab_1[0], lab_1[1], lab_1[2], lab_2[0], lab_2[1], lab_2[2])

	// 5. Display the original HEX color values
	println("Color 1 HEX: ${hex_1}")
	println("Color 2 HEX: ${hex_2}")

	// 6. Display the converted RGB values
	println("Color 1 RGB: ${rgb_1.joinToString(", ", "(", ")")}")
	println("Color 2 RGB: ${rgb_2.joinToString(", ", "(", ")")}")

	// 7. Display the L*a*b* values with formatting to 2 decimal places
	println("Color 1 Lab: (L=${"%.2f".format(lab_1[0])}, a=${"%.2f".format(lab_1[1])}, b=${"%.2f".format(lab_1[2])})")
	println("Color 2 Lab: (L=${"%.2f".format(lab_2[0])}, a=${"%.2f".format(lab_2[1])}, b=${"%.2f".format(lab_2[2])})")

	// 8. Display the final Delta E value with 4 decimal places
	println("Delta E (CIEDE2000) = ${"%.4f".format(delta_e)}")

	// The result shows a perceptible color difference: ΔE2000 ≈ 12.58
}