Last active
May 16, 2024 04:05
-
-
Save linrock/5be4f365c9c9e61eee9e8984ba13cb25 to your computer and use it in GitHub Desktop.
Color inaccuracies from converting 24-bit RGB -> YUV -> 24-bit RGB (BT.709)
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
""" This simulates converting 24-bit RGB values to YUV, then back to 24-bit RGB. | |
Using BT.709 transfer functions: | |
https://en.wikipedia.org/wiki/Rec._709 | |
It demonstrates that converting to 30-bit YUV then back to 24-bit RGB is lossy. | |
Using 10 bits per YUV value appears to be lossless. | |
Converting RGB (24-bit) -> YUV (64-bit floats per channel, normalized [0-1]) -> RGB (24-bit) | |
Found 0 inaccurate conversions out of 16581375 RGB values | |
Converting RGB (24-bit) -> YUV (30-bit) -> RGB (24-bit) | |
Found 0 inaccurate conversions out of 16581375 RGB values | |
Converting RGB (24-bit) -> YUV (24-bit) -> RGB (24-bit) | |
Found 4058422 accurate conversions out of 16581375 RGB values | |
Found 12522953 inaccurate conversions out of 16581375 RGB values | |
Off by: {1: 8786792, 2: 3727753, 3: 8408} | |
""" | |
# The range of UV values in BT.709 is [-Umax, Umax] and [-Vmax, Vmax] | |
Umax = 0.436 | |
Vmax = 0.615 | |
# Constants used in BT.709 | |
Wr = 0.2126 | |
Wb = 0.0722 | |
# Constants used in BT.601 | |
# Wr = 0.299 | |
# Wb = 0.114 | |
Wg = 1 - Wr - Wb | |
def rgb_to_yuv(rgb, normalize=False, is_8bit=False, is_10bit=False): | |
[r, g, b] = rgb | |
r = r / 255.0 | |
g = g / 255.0 | |
b = b / 255.0 | |
y = Wr * r + Wg * g + Wb * b | |
u = Umax * (b - y) / (1 - Wb) | |
v = Vmax * (r - y) / (1 - Wr) | |
# y[0, 1] u[-Umax, Umax] v[-Vmax, Vmax] | |
if normalize: | |
u = (u + Umax) / (2 * Umax) | |
v = (v + Vmax) / (2 * Vmax) | |
# y[0, 1] u[0, 1] v[0, 1] | |
if is_8bit: | |
y = round(y * 255) | |
u = round(u * 255) | |
v = round(v * 255) | |
# y[0, 255] u[0, 255] v[0, 255] | |
if is_10bit: | |
y = round(y * 1023) | |
u = round(u * 1023) | |
v = round(v * 1023) | |
# y[0, 1023] u[0, 1023] v[0, 1023] | |
return [y, u, v] | |
def yuv_to_rgb(yuv, normalized=False, is_8bit=False, is_10bit=False): | |
[y, u, v] = yuv | |
if is_8bit: | |
# y[0, 255] u[0, 255] v[0, 255] | |
y = y / 255.0 | |
u = u / 255.0 | |
v = v / 255.0 | |
if is_10bit: | |
# y[0, 1023] u[0, 1023] v[0, 1023] | |
y = y / 1023.0 | |
u = u / 1023.0 | |
v = v / 1023.0 | |
if normalized: | |
# y [0, 1], u [0, 1], v[0, 1] | |
u = (u - 0.5) * 2 * Umax | |
v = (v - 0.5) * 2 * Vmax | |
# y [0, 1], u [-Umax, Umax], v[-Vmax, Vmax] | |
# r = y + 1.28033 * v | |
# g = y - 0.21482 * u - 0.38059 * v | |
# b = y + 2.12798 * u | |
r = y + v * (1 - Wr) / Vmax | |
g = y - (u * Wb * (1 - Wb) / (Umax * Wg)) - (v * Wr * (1 - Wr) / (Vmax * Wg)) | |
b = y + u * (1 - Wb) / Umax | |
return [round(r * 255), round(g * 255), round(b * 255)] | |
def print_before_and_after(rgb): | |
print("rgb before: {}".format(rgb)) | |
print("rgb after: {}".format(yuv_to_rgb(rgb_to_yuv(rgb)))) | |
print("") | |
def check_yuv_to_rgb(): | |
num_checked = 0 | |
num_inaccurate = 0 | |
for r in range(0, 255): | |
for g in range(0, 255): | |
for b in range(0, 255): | |
rgb = [r, g, b] | |
converted_rgb = yuv_to_rgb(rgb_to_yuv(rgb)) | |
# print("{} {}".format(rgb, converted_rgb)) | |
if rgb != converted_rgb: | |
num_inaccurate += 1 | |
num_checked += 1 | |
print("Converted full RGB range (24-bit) -> YUV (64-bit floats per channel) -> RGB (24-bit)") | |
print(" Checked {} RGB values - found {} inaccurate conversions".format(num_checked, num_inaccurate)) | |
print("") | |
def check_yuv_normalized_to_rgb(): | |
""" Converts 24-bit RGB to normalized YUV (64-bit floats per channel) YUV range [0-1] | |
then back to 24-bit RGB. This appears to be lossless. | |
""" | |
num_checked = 0 | |
num_inaccurate = 0 | |
for r in range(0, 255): | |
for g in range(0, 255): | |
for b in range(0, 255): | |
rgb = [r, g, b] | |
converted_rgb = yuv_to_rgb(rgb_to_yuv(rgb, normalize=True), normalized=True) | |
if rgb != converted_rgb: | |
num_inaccurate += 1 | |
# print("{} {}".format(rgb, converted_rgb)) | |
num_checked += 1 | |
print("Converted full RGB range (24-bit) -> YUV (64-bit floats per channel, normalized [0-1]) -> RGB (24-bit)") | |
print(" Checked {} RGB values - found {} inaccurate conversions".format(num_checked, num_inaccurate)) | |
print("") | |
def check_8bit_yuv_normalized_to_rgb(): | |
""" Converts 24-bit RGB to 24-bit YUV (8 bits per channel), then back to 24-bit RGB | |
This appears to be lossy. | |
""" | |
num_checked = 0 | |
num_inaccurate = 0 | |
off_by = {1: 0, 2: 0, 3: 0} | |
for r in range(0, 255): | |
for g in range(0, 255): | |
for b in range(0, 255): | |
rgb = [r, g, b] | |
yuv = rgb_to_yuv(rgb, normalize=True, is_8bit=True) | |
conv_rgb = yuv_to_rgb(yuv, normalized=True, is_8bit=True) | |
if rgb != conv_rgb: | |
num_inaccurate += 1 | |
diff = abs(rgb[0] - conv_rgb[0]) + abs(rgb[1] - conv_rgb[1]) + abs(rgb[2] - conv_rgb[2]) | |
off_by[diff] = off_by.get(diff, 0) + 1 | |
num_checked += 1 | |
print("Converted full RGB range (24-bit) -> YUV (24-bit) -> RGB (24-bit)") | |
print(" Checked {} RGB values - found {} accurate conversions".format(num_checked, num_checked - num_inaccurate)) | |
print(" - found {} inaccurate conversions".format(num_inaccurate)) | |
print(" RGB values off by: {}".format(off_by)) | |
print("") | |
def check_10bit_yuv_normalized_to_rgb(): | |
""" Converts 24-bit RGB to 30-bit YUV (10 bits per channel), then back to 24-bit RGB | |
This appears to be lossless. | |
""" | |
num_checked = 0 | |
num_inaccurate = 0 | |
for r in range(0, 255): | |
for g in range(0, 255): | |
for b in range(0, 255): | |
rgb = [r, g, b] | |
yuv = rgb_to_yuv(rgb, normalize=True, is_10bit=True) | |
converted_rgb = yuv_to_rgb(yuv, normalized=True, is_10bit=True) | |
if rgb != converted_rgb: | |
num_inaccurate += 1 | |
num_checked += 1 | |
print("Converted full RGB range (24-bit) -> YUV (30-bit) -> RGB (24-bit)") | |
print(" Checked {} RGB values - found {} inaccurate conversions".format(num_checked, num_inaccurate)) | |
print("") | |
if __name__ == '__main__': | |
print("Sanity check") | |
print_before_and_after([255, 255, 255]) | |
print_before_and_after([255, 0, 0]) | |
print_before_and_after([0, 255, 0]) | |
print_before_and_after([0, 0, 255]) | |
print_before_and_after([0, 0, 0]) | |
# check_yuv_to_rgb() | |
check_yuv_normalized_to_rgb() | |
check_10bit_yuv_normalized_to_rgb() | |
check_8bit_yuv_normalized_to_rgb() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment