Skip to content

Instantly share code, notes, and snippets.

@iuriguilherme
Last active February 2, 2023 00:20
Show Gist options
  • Save iuriguilherme/27eb85a5750c299a8b935b14b6cc7942 to your computer and use it in GitHub Desktop.
Save iuriguilherme/27eb85a5750c299a8b935b14b6cc7942 to your computer and use it in GitHub Desktop.
Finds the best contrast for each color on the 8bit RGB scope
"""Find contrast ratio for colors
TODO: Use scrollbars for color list
pip install numpy"""
import ast
import csv
import functools
import inspect
import logging
import queue
import numpy
import sys
import threading
import tkinter
log_level: int | str | None = logging.INFO
try:
log_level: int | str | None = sys.argv[1].upper()
logging.basicConfig(level = log_level)
except:
logging.basicConfig(level = log_level)
## Produces a number between 0 and 255 proportional to i relative to m
rgb: int = lambda i, m: round(i * (255 / max(1, m)))
## Returns the oposite RGB tuple in the 0-255 range
irgb: tuple[int] = lambda r: tuple(abs(255 - c) for c in r)
## Root Mean Square
rms: float = lambda t: (sum(i * i for i in t) / len(t)) ** 0.5
nrms: float = lambda a: numpy.sqrt(numpy.mean(a ** 2))
## Returns RGB subtracted by its RMS
rrgb: tuple[int] = lambda rgb: tuple(abs(round(rms(rgb)) - c) \
for c in rgb)
nrgb: tuple[int] = lambda rgb: tuple(abs(round(nrms(rgb)) - c) \
for c in rgb)
## https://www.w3.org/TR/WCAG20/#relativeluminancedef
srgb: tuple[float] = lambda rgb: tuple(max(1, c) / 255 for c in rgb)
rel: float = lambda srgb: tuple(c / 12.92 if c <= 0.04045 else \
((c + 0.055) / 1.055) ** 2.4 for c in srgb)
lum: float = lambda r, g, b: r * 0.2126 + g * 0.7152 + b * 0.0722
rl: float = lambda rgb: lum(*(c for c in rel(srgb(rgb))))
## https://www.w3.org/TR/WCAG20/#contrast-ratiodef
cr: float = lambda l1, l2: (max(l1, l2) * 5e-2) / (min(l1, l2) * 5e-2)
## https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast
crl: float = lambda a, b: cr(rl(a), rl(b))
## https://stackoverflow.com/questions/57651030/
## take-hex-code-rgb-and-display-the-color-in-tkinter
h2r: tuple[int] = lambda h: tuple(int(h.lstrip('#')[n:n + 2], 16) \
for n in range(0, 6, 2))
r2h: str = lambda r: f"#{''.join([f'{c:02X}' for c in r])}"
@functools.cache
def check_contrast(rgb1, rgb2, *args, **kwargs) -> float:
"""Compare contrast of two colors"""
return crl(rgb1, rgb2)
def main(*args, **kwargs) -> None:
"""main"""
logger: logging.Logger = logging.getLogger(
inspect.currentframe().f_code.co_name)
logger.setLevel(kwargs.get('log_level', log_level))
speed: int = kwargs.get('speed', 1)
root_queue: queue.Queue = queue.Queue()
color_left_queue: queue.Queue = queue.Queue()
color_right_queue: queue.Queue = queue.Queue()
color_done_queue: queue.Queue = queue.Queue()
speed_queue: queue.Queue = queue.Queue()
def tkinter_thread_callback(
color_thread: threading.Thread,
*args,
speed: int = speed,
root_queue: queue.Queue = root_queue,
color_left_queue: queue.Queue = color_left_queue,
color_right_queue: queue.Queue = color_right_queue,
color_done_queue: queue.Queue = color_done_queue,
speed_queue: queue.Queue = speed_queue,
contrasts: dict[str, object] = {},
filename: str = 'contrasts.csv',
**kwargs,
) -> None:
"""tkinter main thread"""
logger: logging.Logger = logging.getLogger(
inspect.currentframe().f_code.co_name)
logger.setLevel(kwargs.get('log_level', log_level))
try:
color_left: tuple[int] = (0, 0, 0)
color_right: tuple[int] = (0, 0, 0)
def save(*args, destroy: bool = False, **kwargs) -> None:
"""Save current state to CSV file"""
try:
with open(filename, 'w', newline = '') as csvfile:
writer: csv.DictWriter = csv.DictWriter(
csvfile,
fieldnames = [
'color',
'contrast',
'last_tested',
'ratio',
'tested',
])
writer.writeheader()
for key, value in contrasts.items():
writer.writerow({'color': key, **{k: v for \
k, v in value.items() if k not in [
'frame',
'label_contrast',
'label_left',
'label_ratio',
'label_right',
'label_tested',
]}})
if destroy:
root.destroy()
except Exception as e:
logger.exception(e)
def new_color(
*args,
color: tuple[int] = (0, 0, 0),
contrast: tuple[int] = (0, 0, 0),
last_tested: float = (0, 0, 0),
ratio: float = 0.0,
tested: float = 0.0,
**kwargs,
) -> None:
"""Add new color to the list"""
hexcode: str = r2h(color)
contrasts[color]: dict = {
'contrast': contrast,
'ratio': ratio,
'tested': tested,
'last_tested': last_tested,
'frame': tkinter.Frame(color_list_scroll_frame),
}
contrasts[color].update(
label_left = tkinter.Label(
contrasts[color].get('frame'),
text = f"{hexcode} {color}",
bg = hexcode,
fg = r2h(irgb(color)),
),
label_contrast = tkinter.Label(
contrasts[color].get('frame'),
text = f"{r2h(contrast)} {contrast}",
bg = r2h(contrast),
fg = r2h(irgb(contrast)),
),
label_right = tkinter.Label(
contrasts[color].get('frame'),
text = f"{r2h(last_tested)} {last_tested}",
bg = r2h(last_tested),
fg = r2h(irgb(last_tested)),
),
label_ratio = tkinter.Label(
contrasts[color].get('frame'),
text = f"{ratio}",
),
label_tested = tkinter.Label(
contrasts[color].get('frame'),
text = f"{tested:%}",
)
)
contrasts[color].get('frame').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
tkinter.Label(
contrasts[color].get('frame'),
text = "Color:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrasts[color].get('label_left').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
tkinter.Label(
contrasts[color].get('frame'),
text = "Best contrast:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrasts[color].get('label_contrast').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
tkinter.Label(
contrasts[color].get('frame'),
text = "Ratio:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrasts[color].get('label_ratio').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
tkinter.Label(
contrasts[color].get('frame'),
text = "Last tested:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrasts[color].get('label_right').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
tkinter.Label(
contrasts[color].get('frame'),
text = "Tested:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrasts[color].get('label_tested').pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
def change_speed(
event: tkinter.Event,
speed: int,
*args,
**kwargs,
) -> None:
"""Change current speed"""
logger: logging.Logger = logging.getLogger(
inspect.currentframe().f_code.co_name)
logger.setLevel(kwargs.get('log_level', log_level))
try:
try:
speed: int = speed_listbox.curselection()[0]
except:
pass
speed_label.config(
text = f"Current speed: {speed} Change:")
speed_queue.put(speed)
except Exception as e:
logger.exception(e)
def change_color(
event: tkinter.Event | None = None,
target: tkinter.Label | None = None,
sources: list[tkinter.Listbox] | None = None,
rgb: tuple[int] | None = None,
update: bool = False,
*args,
**kwargs,
) -> None:
"""Change current left color"""
logger: logging.Logger = logging.getLogger(
inspect.currentframe().f_code.co_name)
logger.setLevel(kwargs.get('log_level', log_level))
try:
if not target:
return
hexcode: list | str = '#000000'
if sources:
hexcode: list | str = ['#']
for n in range(len(sources)):
try:
hexcode.append(
f"{sources[n].curselection()[0]:X}")
except:
hexcode.append(f"{0:X}")
hexcode: list | str = ''.join(hexcode)
color: tuple[int] = h2r(hexcode)
elif rgb:
color: tuple[int] = rgb
hexcode: str = r2h(rgb)
target.config(
text = f"{hexcode} {color}",
bg = hexcode,
fg = r2h(irgb(color)),
)
if update:
color_left: tuple[int] = color
if color_left not in contrasts:
new_color(color = color)
color_left_queue.put((
color_left,
contrasts[color_left].get('tested'),
contrasts[color_left].get('last_tested'),
speed
))
except Exception as e:
logger.exception(e)
root: tkinter.Tk = tkinter.Tk()
root.title("Contrast Ratio Test")
if all(x in kwargs for x in ('height', 'width')):
root.geometry("x".join([
str(kwargs['root_width']),
str(kwargs['root_height']),
]))
color_chooser_frame: tkinter.Frame = tkinter.Frame(root)
color_left_right_frame: tkinter.Frame = tkinter.Frame(root)
color_left_frame: tkinter.Frame = tkinter.Frame(
color_left_right_frame)
color_right_frame: tkinter.Frame = tkinter.Frame(
color_left_right_frame)
contrast_current_frame: tkinter.Frame = tkinter.Frame(root)
contrast_best_frame: tkinter.Frame = tkinter.Frame(root)
color_list_frame: tkinter.Frame = tkinter.Frame(root)
color_list_canvas: tkinter.Canvas = tkinter.Canvas(
color_list_frame)
color_list_scrollbar: tkinter.Scrollbar = tkinter.Scrollbar(
color_list_frame,
orient = "vertical",
command = color_list_canvas.yview,
)
color_list_scroll_frame: tkinter.Frame = tkinter.Frame(
color_list_canvas)
# ~ color_list_scroll_frame.bind(
# ~ "<Configure>",
# ~ lambda event: color_list_canvas.configure(
# ~ scrollregion = color_list_canvas.bbox("all")
# ~ )
# ~ )
color_list_canvas.create_window(
(0, 0),
window = color_list_scroll_frame,
anchor = "center",
)
color_list_canvas.configure(
yscrollcommand = color_list_scrollbar.set)
color_chooser_label: tkinter.Label = tkinter.Label(
color_chooser_frame,
text = "Select color to calculate ratio:",
)
color_left_label: tkinter.Label = tkinter.Label(
color_left_frame)
color_right_label: tkinter.Label = tkinter.Label(
color_right_frame,
justify = "left",
)
color_list_label_frame: tkinter.Frame = tkinter.Frame(root)
contrast_current_label: tkinter.Label = tkinter.Label(
contrast_current_frame)
contrast_best_label: tkinter.Label = tkinter.Label(
contrast_best_frame)
color_list_label: tkinter.Label = tkinter.Label(
color_list_label_frame,
text = "Bests contrasts:",
)
save_button: tkinter.Button = tkinter.Button(
color_chooser_frame,
text = "Save to CSV",
command = save,
)
save_exit_button: tkinter.Button = tkinter.Button(
color_chooser_frame,
text = "Save & Exit",
command = lambda destroy = True: save(
destroy = destroy),
)
exit_button: tkinter.Button = tkinter.Button(
color_chooser_frame,
text = "Exit",
command = lambda: root.destroy(),
)
speed_label: tkinter.Label = tkinter.Label(
color_chooser_frame,
text = f"Current speed: {speed} Change:",
)
speed_listbox: tkinter.Listbox = tkinter.Listbox(
color_chooser_frame,
listvariable = tkinter.Variable(
value = list(range(speed_limit + 1))),
width = 2,
height = 2,
)
speed_listbox.bind("<<ListboxSelect>>",
lambda event: change_speed(event, speed))
color_chooser_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_chooser_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
color_chooser_hex_listboxes: list[tkinter.Listbox] = []
for n in range(6):
color_chooser_hex_listboxes.append(
tkinter.Listbox(
color_chooser_frame,
listvariable = tkinter.Variable(
value = [f'{h:X}' for h in range(16)]),
exportselection = False,
width = 2,
height = 2,
)
)
color_chooser_hex_listboxes[n].pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
color_chooser_button: tkinter.Button = tkinter.Button(
color_chooser_frame,
text = "Update",
command = lambda \
sources = color_chooser_hex_listboxes,
target = color_left_label,
update = True: \
change_color(
sources = sources,
target = target,
update = update,
),
)
color_left_right_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_left_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_left_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_chooser_button.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
speed_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
speed_listbox.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
save_button.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
save_exit_button.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
exit_button.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
color_right_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_right_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
contrast_current_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
tkinter.Label(
contrast_current_frame,
text = "Contrast ratio:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrast_current_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrast_best_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
tkinter.Label(
contrast_best_frame,
text = "Best:",
).pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
contrast_best_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.LEFT,
)
color_list_label_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_list_label.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_list_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
color_list_canvas.pack(
fill = tkinter.BOTH,
side = tkinter.LEFT,
expand = True,
)
color_list_scrollbar.pack(
fill = tkinter.Y,
side = tkinter.RIGHT,
)
color_list_scroll_frame.pack(
anchor = tkinter.CENTER,
fill = tkinter.BOTH,
side = tkinter.TOP,
)
# ~ color_list_frame.config(
# ~ yscrollcommand = color_list_scrollbar.set)
for k, v in contrasts.items():
new_color(color = k, **v)
change_color(
target = color_left_label,
rgb = (0, 0, 0),
update = True,
)
contrast_ratio: float = 0.0
tested: float = 0.0
while root.winfo_exists():
try:
color_left, tested, color_right, speed = \
color_right_queue.get(
timeout = float(f"1e-{speed}"))
except queue.Empty:
continue
change_color(
target = color_right_label,
rgb = color_right,
)
contrasts[color_left]['tested'] = tested
contrasts[color_left]['label_tested'].config(
text = f"{tested:%}")
contrasts[color_left]['last_tested'] = color_right
change_color(target = contrasts[color_left].get(
'label_right'), rgb = color_right)
contrast_ratio: float = check_contrast(color_left,
color_right)
if contrast_ratio > contrasts[color_left].get('ratio'):
contrasts[color_left]['ratio'] = contrast_ratio
contrasts[color_left]['contrast'] = color_right
change_color(target = contrasts[color_left].get(
'label_contrast'), rgb = color_right)
contrasts[color_left].get('label_ratio').config(
text = f"{contrasts[color_left].get('ratio')}")
contrast_current_label.config(
text = f"{contrast_ratio}")
contrast_best_label.config(
text = f"{contrasts[color_left].get('ratio')}")
root.update()
except tkinter.TclError:
while color_thread.is_alive():
root_queue.put(False)
except Exception as e:
logger.exception(e)
def color_thread_callback(
*args,
speed: int = speed,
root_queue: queue.Queue = root_queue,
color_left_queue: queue.Queue = color_left_queue,
color_right_queue: queue.Queue = color_right_queue,
color_done_queue: queue.Queue = color_done_queue,
speed_queue: queue.Queue = speed_queue,
**kwargs,
) -> None:
"""Threaded checking for color contrast"""
logger: logging.Logger = logging.getLogger(
inspect.currentframe().f_code.co_name)
logger.setLevel(kwargs.get('log_level', log_level))
try:
root_alive: bool = True
current_color: tuple[int] = (0, 0, 0)
def check_color(
color: tuple[int],
tested: float = 0.0,
start: tuple[int] = (0, 0, 0),
speed: int = speed,
f1: int = 255,
*args,
**kwargs,
) -> tuple[tuple[int], float]:
"""Loop all 8bit RGB space"""
f2 = (f1 * 1 + f1 * 2 + f1 * 3)
r, g, b = start
for r in range(start[0], f1 + 1):
for g in range(start[1], f1 + 1):
for b in range(start[2], f1 + 1):
tested: float = max(
tested,
(
(
(r * (f1 * 3)) + \
(g * (f1 * 2)) + \
(b * (f1 * 1))
) / f2
) / f1
)
try:
if not root_queue.get(
timeout = float(f"1e-{speed}")
):
return (
(0, 0, 0),
1.0,
(255, 255, 255),
0
)
except queue.Empty:
pass
try:
speed = speed_queue.get(
timeout = float(f"1e-{speed}"))
except queue.Empty:
pass
try:
return color_left_queue.get(
timeout = float(f"1e-{speed}"))
except queue.Empty:
pass
color_right_queue.put((
color,
tested,
(r, g, b),
speed
))
return (
color,
tested,
(r, g, b),
speed
)
while root_alive:
try:
root_alive: bool = root_queue.get(
timeout = float(f"1e-{speed}"))
except queue.Empty:
pass
try:
speed: int = speed_queue.get(
timeout = float(f"1e-{speed}"))
except queue.Empty:
pass
tested: float = 0.0
start: tuple[int] = (0, 0, 0)
while tested < 1.0:
current_color, tested, start, speed = check_color(
current_color,
tested,
start,
speed,
)
except Exception as e:
logger.exception(e)
contrasts: dict = {}
try:
with open(filename, newline = '') as csvfile:
reader: csv.DictReader = csv.DictReader(csvfile)
for row in reader:
contrasts[ast.literal_eval(row['color'])] = {k: \
ast.literal_eval(v) for k, v in row.items() if k \
!= 'color'}
except FileNotFoundError:
pass
except Exception as e:
logger.exception(e)
color_thread: threading.Thread = threading.Thread(
target = color_thread_callback,
)
tkinter_thread: threading.Thread = threading.Thread(
target = tkinter_thread_callback,
args = (
color_thread,
),
kwargs = dict(
contrasts = contrasts,
filename = filename,
),
)
tkinter_thread.start()
color_thread.start()
tkinter_thread.join()
print('END')
if __name__ == '__main__':
try:
speed_limit: int = 2
try:
log_level: str = sys.argv[1].upper()
except (IndexError, AttributeError):
log_level: str = 'INFO'
try:
speed: int = int(sys.argv[2])
except (IndexError, TypeError):
speed: int = 1
try:
filename: str = sys.argv[3]
except IndexError:
filename: str = 'contrasts.csv'
print(f"Usage: {sys.argv[0]} {log_level} {speed} {filename}")
if speed > speed_limit:
logging.warning(f"""WARNING: Speed over {speed_limit} will \
break queues. Rounding down to {speed_limit}.""")
speed: int = speed_limit
main(
log_level = log_level,
filename = filename,
speed = speed,
speed_limit = speed_limit,
)
except Exception as e:
logging.exception(e)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment