Skip to content

Instantly share code, notes, and snippets.

@linrock
Last active May 16, 2024 04:05
Show Gist options
  • Save linrock/5be4f365c9c9e61eee9e8984ba13cb25 to your computer and use it in GitHub Desktop.
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 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