Created
May 7, 2025 19:47
-
-
Save meyt/8be00d62b861ec913ca8cad0c29d0aa8 to your computer and use it in GitHub Desktop.
Color Similarity + Conversion (Python)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from math import atan2, cos, exp, fabs, hypot, pi, sin, sqrt | |
from typing import Tuple | |
lab_type = Tuple[float, float, float] | |
hex_type = str # e.g: #ff00ff | |
rgb_type = Tuple[int, int, int] | |
def hex2rgb(v: hex_type) -> rgb_type: | |
return int(v[1:3], 16), int(v[3:5], 16), int(v[5:7], 16) | |
def srgb2linear(c: int) -> float: | |
c = c / 255.0 | |
return c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4) | |
def rgb2lab(r: int, g: int, b: int) -> lab_type: | |
r, g, b = srgb2linear(r), srgb2linear(g), srgb2linear(b) | |
# Convert linear RGB to XYZ (D65) | |
X = r * 0.4124564 + g * 0.3575761 + b * 0.1804375 | |
Y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750 | |
Z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041 | |
# Reference white D65 | |
Xn, Yn, Zn = 0.95047, 1.00000, 1.08883 | |
def f(t): | |
d = 6 / 29 | |
return pow(t, 1 / 3) if t > pow(d, 3) else t / (3 * d * d) + 4 / 29 | |
fx, fy, fz = f(X / Xn), f(Y / Yn), f(Z / Zn) | |
L = 116 * fy - 16 | |
a = 500 * (fx - fy) | |
b = 200 * (fy - fz) | |
return L, a, b | |
def hex2lab(v: hex_type) -> lab_type: | |
return rgb2lab(*hex2rgb(v)) | |
def rad2deg(rad): | |
return (360 * rad) / (2 * pi) | |
def deg2rad(deg): | |
return (2 * pi * deg) / 360 | |
def ciede2000(a: lab_type, b: lab_type) -> float: | |
""" | |
The classic CIE ΔE2000 implementation, which operates on two L*a*b* colors, | |
and returns their difference. | |
credits: https://github.com/michel-leonard | |
L1 = 33.0 a1 = 56.551 b1 = 107.34 | |
L2 = 33.0 a2 = 56.551 b2 = 107.41 | |
CIE ΔE2000 = ΔE00 = 0.0182199484 | |
L1 = 57.751 a1 = -64.8572 b1 = 104.6669 | |
L2 = 57.751 a2 = -65.0 b2 = 104.6669 | |
CIE ΔE2000 = ΔE00 = 0.04105095889 | |
L1 = 51.2347 a1 = -53.74 b1 = -85.6 | |
L2 = 51.2347 a2 = -54.0 b2 = -85.6 | |
CIE ΔE2000 = ΔE00 = 0.07251289185 | |
L1 = 27.5 a1 = -38.963 b1 = 102.02 | |
L2 = 27.5 a2 = -39.0 b2 = 101.6285 | |
CIE ΔE2000 = ΔE00 = 0.09222135798 | |
L1 = 58.207 a1 = -105.0 b1 = -79.71 | |
L2 = 58.207 a2 = -106.2 b2 = -79.71 | |
CIE ΔE2000 = ΔE00 = 0.23933030702 | |
L1 = 18.6 a1 = 5.4 b1 = 81.0 | |
L2 = 18.6 a2 = 5.4 b2 = 90.0 | |
CIE ΔE2000 = ΔE00 = 1.87770163489 | |
L1 = 32.913 a1 = 3.0961 b1 = 115.117 | |
L2 = 32.913 a2 = 8.4101 b2 = 115.117 | |
CIE ΔE2000 = ΔE00 = 2.60306648026 | |
L1 = 49.0 a1 = -12.0 b1 = 124.282 | |
L2 = 53.0 a2 = -12.0 b2 = 116.703 | |
CIE ΔE2000 = ΔE00 = 4.16877634871 | |
L1 = 21.032 a1 = -30.4 b1 = -68.783 | |
L2 = 26.0 a2 = -14.9 b2 = -54.29 | |
CIE ΔE2000 = ΔE00 = 8.23700340366 | |
L1 = 76.5 a1 = -83.7311 b1 = 10.0 | |
L2 = 97.08 a2 = -59.472 b2 = 28.1 | |
CIE ΔE2000 = ΔE00 = 17.25538447747 | |
""" | |
l_1, a_1, b_1 = a | |
l_2, a_2, b_2 = b | |
# Working in Python 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... | |
k_l = k_c = k_h = 1.0 | |
n = (hypot(a_1, b_1) + hypot(a_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))) | |
# hypot calculates the Euclidean distance while avoiding overflow/underflow | |
c_1 = hypot(a_1 * n, b_1) | |
c_2 = hypot(a_2 * n, 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. | |
h_1 = atan2(b_1, a_1 * n) | |
h_2 = atan2(b_2, a_2 * n) | |
h_1 += 2.0 * pi * (h_1 < 0.0) | |
h_2 += 2.0 * pi * (h_2 < 0.0) | |
n = abs(h_2 - h_1) | |
# Cross-implementation consistent rounding. | |
if pi - 1e-14 < n and 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. | |
h_m = (h_1 + h_2) * 0.5 | |
h_d = (h_2 - h_1) * 0.5 | |
if pi < n: | |
if 0.0 < h_d: | |
h_d -= pi | |
else: | |
h_d += pi | |
h_m += pi | |
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. | |
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. | |
lt = (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. | |
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. | |
h = 2.0 * sqrt(c_1 * c_2) * sin(h_d) / (k_h * (1.0 + 0.0075 * n * t)) | |
# Chroma. | |
c = (c_2 - c_1) / (k_c * (1.0 + 0.0225 * n)) | |
# Returning the square root ensures that the result represents | |
# the "true" geometric distance in the color space. | |
return sqrt(lt * lt + h * h + c * c + c * h * r_t) | |
def ciede2000_weighted(a: lab_type, b: lab_type, Kl=1, Kc=1, Kh=1) -> float: | |
""" | |
Computes color difference as developed by the International Commission on | |
Illumination (CIE) in 2000. The implementation is based on the formula | |
from Bruce Lindbloom. Resulting values range from 0 (no difference) | |
to 100 (maximum difference), and are a metric for how the human eye | |
percieves color difference. | |
The optional parameters Kl, Kc, and Kh may be used to adjust | |
weightings of lightness, chroma, and hue. | |
credits: https://github.com/gka/chroma.js | |
""" | |
L1, a1, b1 = a | |
L2, a2, b2 = b | |
avgL = (L1 + L2) / 2 | |
C1 = sqrt(a1 * a1 + b1 * b1) | |
C2 = sqrt(a2 * a2 + b2 * b2) | |
avgC = (C1 + C2) / 2 | |
G = 0.5 * (1 - sqrt(pow(avgC, 7) / (pow(avgC, 7) + pow(25, 7)))) | |
a1p = a1 * (1 + G) | |
a2p = a2 * (1 + G) | |
C1p = sqrt(a1p * a1p + b1 * b1) | |
C2p = sqrt(a2p * a2p + b2 * b2) | |
avgCp = (C1p + C2p) / 2 | |
h1p = rad2deg(atan2(b1, a1p)) | |
if h1p < 0: | |
h1p += 360 | |
h2p = rad2deg(atan2(b2, a2p)) | |
if h2p < 0: | |
h2p += 360 | |
avgHp = (h1p + h2p + 360) / 2 if fabs(h1p - h2p) > 180 else (h1p + h2p) / 2 | |
T = ( | |
1 | |
- 0.17 * cos(deg2rad(avgHp - 30)) | |
+ 0.24 * cos(deg2rad(2 * avgHp)) | |
+ 0.32 * cos(deg2rad(3 * avgHp + 6)) | |
- 0.20 * cos(deg2rad(4 * avgHp - 63)) | |
) | |
deltaHp = h2p - h1p | |
if fabs(deltaHp) > 180: | |
deltaHp += 360 if h2p <= h1p else -360 | |
deltaHp = 2 * sqrt(C1p * C2p) * sin(deg2rad(deltaHp / 2)) | |
deltaL = L2 - L1 | |
deltaCp = C2p - C1p | |
Sl = 1 + (0.015 * pow(avgL - 50, 2)) / sqrt(20 + pow(avgL - 50, 2)) | |
Sc = 1 + 0.045 * avgCp | |
Sh = 1 + 0.015 * avgCp * T | |
deltaTheta = 30 * exp(-pow((avgHp - 275) / 25, 2)) | |
Rc = 2 * sqrt(pow(avgCp, 7) / (pow(avgCp, 7) + pow(25, 7))) | |
Rt = -Rc * sin(deg2rad(2 * deltaTheta)) | |
result = sqrt( | |
pow(deltaL / (Kl * Sl), 2) | |
+ pow(deltaCp / (Kc * Sc), 2) | |
+ pow(deltaHp / (Kh * Sh), 2) | |
+ Rt * (deltaCp / (Kc * Sc)) * (deltaHp / (Kh * Sh)) | |
) | |
return max(0, min(100, result)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import pytest | |
from ciede import ciede2000, ciede2000_weighted, hex2lab | |
# Test cases derived from | |
# http://www2.ece.rochester.edu/~gsharma/ciede2000/dataNprograms | |
# /ciede2000testdata.txt | |
@pytest.mark.parametrize( | |
"lab1, lab2, expected", | |
[ | |
([50.0000, 2.6772, -79.7751], [50.0000, 0.0000, -82.7485], 2.0425), | |
([50.0000, 3.1571, -77.2803], [50.0000, 0.0000, -82.7485], 2.8615), | |
([50.0000, 2.8361, -74.0200], [50.0000, 0.0000, -82.7485], 3.4412), | |
([50.0000, -1.3802, -84.2814], [50.0000, 0.0000, -82.7485], 1.0000), | |
([50.0000, -1.1848, -84.8006], [50.0000, 0.0000, -82.7485], 1.0000), | |
([50.0000, -0.9009, -85.5211], [50.0000, 0.0000, -82.7485], 1.0000), | |
([50.0000, 0.0000, 0.0000], [50.0000, -1.0000, 2.0000], 2.3669), | |
([50.0000, -1.0000, 2.0000], [50.0000, 0.0000, 0.0000], 2.3669), | |
([50.0000, 2.4900, -0.0010], [50.0000, -2.4900, 0.0009], 7.1792), | |
([50.0000, 2.4900, -0.0010], [50.0000, -2.4900, 0.0010], 7.1792), | |
([50.0000, 2.4900, -0.0010], [50.0000, -2.4900, 0.0011], 7.2195), | |
([50.0000, 2.4900, -0.0010], [50.0000, -2.4900, 0.0012], 7.2195), | |
([50.0000, -0.0010, 2.4900], [50.0000, 0.0009, -2.4900], 4.8045), | |
([50.0000, -0.0010, 2.4900], [50.0000, 0.0010, -2.4900], 4.8045), | |
([50.0000, -0.0010, 2.4900], [50.0000, 0.0011, -2.4900], 4.7461), | |
([50.0000, 2.5000, 0.0000], [50.0000, 0.0000, -2.5000], 4.3065), | |
([50.0000, 2.5000, 0.0000], [73.0000, 25.0000, -18.0000], 27.1492), | |
([50.0000, 2.5000, 0.0000], [61.0000, -5.0000, 29.0000], 22.8977), | |
([50.0000, 2.5000, 0.0000], [56.0000, -27.0000, -3.0000], 31.9030), | |
([50.0000, 2.5000, 0.0000], [58.0000, 24.0000, 15.0000], 19.4535), | |
([50.0000, 2.5000, 0.0000], [50.0000, 3.1736, 0.5854], 1.0000), | |
([50.0000, 2.5000, 0.0000], [50.0000, 3.2972, 0.0000], 1.0000), | |
([50.0000, 2.5000, 0.0000], [50.0000, 1.8634, 0.5757], 1.0000), | |
([50.0000, 2.5000, 0.0000], [50.0000, 3.2592, 0.3350], 1.0000), | |
([60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644), | |
([63.0109, -31.0961, -5.8663], [62.8187, -29.7946, -4.0864], 1.2630), | |
([61.2901, 3.7196, -5.3901], [61.4292, 2.2480, -4.9620], 1.8731), | |
([35.0831, -44.1164, 3.7933], [35.0232, -40.0716, 1.5901], 1.8645), | |
([22.7233, 20.0904, -46.6940], [23.0331, 14.9730, -42.5619], 2.0373), | |
([36.4612, 47.8580, 18.3852], [36.2715, 50.5065, 21.2231], 1.4146), | |
([90.8027, -2.0831, 1.4410], [91.1528, -1.6435, 0.0447], 1.4441), | |
([90.9257, -0.5406, -0.9208], [88.6381, -0.8985, -0.7239], 1.5381), | |
([6.7747, -0.2908, -2.4247], [5.8714, -0.0985, -2.2286], 0.6377), | |
([2.0776, 0.0795, -1.1350], [0.9033, -0.0636, -0.5514], 0.9082), | |
], | |
) | |
def test_ciede2000(lab1, lab2, expected): | |
result = ciede2000(lab1, lab2) | |
assert round(result, 4) == expected | |
result = ciede2000_weighted(lab1, lab2) | |
assert round(result, 4) == expected | |
@pytest.mark.parametrize( | |
"input_colors, expected", | |
( | |
((0x000000, 0x000000), 0), | |
((0xFFFFFF, 0x000000), 100), | |
((0xFF0000, 0x00FF00), 86.6082374535373), | |
((0x00FF00, 0xFF0000), 86.6082374535373), | |
((0x00BEEF, 0x00BEEE), 0.28076037937072745), | |
((0xFFFF00, 0x0000FF), 100), | |
), | |
) | |
def test_delta_e(input_colors, expected): | |
c1 = hex2lab(f"#{input_colors[0]:06x}") | |
c2 = hex2lab(f"#{input_colors[1]:06x}") | |
result = ciede2000_weighted(c1, c2) | |
assert round(result, 4) == round(expected, 4) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment