2
\$\begingroup\$

This question is part of a series of posts about my massive Tic Tac Toe game with AI players, the previous question is: GUI Tic-Tac-Toe game with six AI players - part 1: the UI. This question includes all the code necessary to stylize the GUI.

The final part is: GUI Tic-Tac-Toe game with AI, part 3: control and logic

I have created a GitHub repository for this project, there you can find everything you need to run the project. To run the program, download the repository as a zip package, and run analyze_tic_tac_toe_states.py first, you only need to run it once to create the necessary data files. And then you can run the game by running main.py.

You can also see it in action here.

enter image description here

enter image description hereenter image description hereenter image description here

enter image description here

enter image description here

enter image description here

In this question I will post the code I used to colorize the grayscale images, the classes I used to generate a new stylesheet dynamically using a dictionary, the code I used to convert between color spaces, and the code I used to create a colorized picture dynamically to represent the players.

I wrote all of the code on my own, I implemented the blend modes according to formulas online, and color space conversion also according formulas I found online.

References:

https://en.wikipedia.org/wiki/Blend_modes

https://www.w3.org/TR/compositing-1/

https://docs.gimp.org/en/gimp-concepts-layer-modes.html

https://en.wikipedia.org/wiki/SRGB

https://en.wikipedia.org/wiki/CIELAB_color_space

https://gitlab.gnome.org/GNOME/babl

http://dev.vkdev.pro/2013/01/lch-color-model-photoshop-blend-modes.html

https://ninedegreesbelow.com/photography/srgb-color-space-to-profile.html

http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html

https://gist.github.com/floz/53ad2765cc846187cdd3

I implemented the color conversion functions and blending modes using NumPy and Numba, because I use Numba the functions need to be compiled first, and so running them for the first time may take some time, but this ensures every subsequent calls of the same functions are fast. Most functions take about 20 milliseconds or so to process a 1920x1080 image, but the functions related to conversion between RGB and CIE-LChab can take around 200 milliseconds for a 1080P image. I have tried to speed it up but I am unable to do so, even when I implemented it in C++ it remained slow.

I have also implemented the code to generate RGB and XYZ conversion matrix myself, to maximum double precision, without any rounding, using the formulas I found online, and the values I got from babl source code.

The styling is done by taking key value pairs from a dictionary, and translate the keys into stylesheet attributes, and format the pairs in stylesheet format, the values are either the values from the dictionary directly, or a string formatted with 'lowlight' and 'highlight' values in case of gradients.

Basically it is a serialization of sorts, similar to JSON, each attribute name is followed by a colon and then the attribute value, the pairs are separated by semicolons instead of commas, and there are pairs of curly brackets enclosing the entry, I let the class dynamically create the stylesheet using the dictionary and formatting rules, and add a string before the opening curly bracket to indicate which widget the style should be applied to.

And finally I wrote the code to put all these together to create the final pieces on the fly.


compute_XYZ_RGB_matrices.py

(not part of the project itself, but closely related)

import numpy as np
from typing import Tuple


def xy_to_XY(x: float, y: float) -> Tuple[float]:
    return x / y, (1 - x - y) / y


BRADFORD = np.array(
    [
        [0.8951000, 0.2664000, -0.1614000],
        [-0.7502000, 1.7135000, 0.0367000],
        [0.0389000, -0.0685000, 1.0296000],
    ],
    dtype=float,
)

BRADFORD_INV = np.linalg.inv(BRADFORD)

D65 = np.array([0.9504559270516716, 1, 1.0890577507598784], dtype=float)
D50 = np.array([0.96420288, 1, 0.82490540], dtype=float)
CHAD = np.zeros(9)
CHAD[:9:4] = (BRADFORD @ D50) / (BRADFORD @ D65)
CHAD = BRADFORD_INV @ CHAD.reshape((3, 3)) @ BRADFORD


Rx = 0.639998686
Ry = 0.330010138
Gx = 0.300003784
Gy = 0.600003357
Bx = 0.150002046
By = 0.059997204

Xr, Zr = xy_to_XY(Rx, Ry)
Xg, Zg = xy_to_XY(Gx, Gy)
Xb, Zb = xy_to_XY(Bx, By)
INTERIMAT = np.array([[Xr, Xg, Xb], [1, 1, 1], [Zr, Zg, Zb]], dtype=float)
MATINV = np.linalg.inv(INTERIMAT)
D65_Y = MATINV @ D65
D65_XYZ = INTERIMAT * D65_Y


def xyY_to_XYZ(x: float, y: float, Y: float) -> Tuple[float]:
    return x * Y / y, Y, (1 - x - y) * Y / y


D50_XYZ = np.vstack(
    [
        CHAD @ xyY_to_XYZ(Rx, Ry, D65_Y[0]),
        CHAD @ xyY_to_XYZ(Gx, Gy, D65_Y[1]),
        CHAD @ xyY_to_XYZ(Bx, By, D65_Y[2]),
    ]
).T

D65_RGB = np.linalg.inv(D65_XYZ)
D50_RGB = np.linalg.inv(D50_XYZ)

for i, c in enumerate("XYZ"):
    for j, d in enumerate("rgb"):
        print(f"D65_{c}{d} = {D65_XYZ[i, j]}")

print()
for i, c in enumerate("RGB"):
    for j, d in enumerate("xyz"):
        print(f"D65_{c}{d} = {D65_RGB[i, j]}")

print()
for i, c in enumerate("XYZ"):
    for j, d in enumerate("rgb"):
        print(f"D50_{c}{d} = {D50_XYZ[i, j]}")

print()
for i, c in enumerate("RGB"):
    for j, d in enumerate("xyz"):
        print(f"D50_{c}{d} = {D50_RGB[i, j]}")

color.py

import numba as nb
import numpy as np
from math import atan2, cos, sin, pi
from typing import Callable, Tuple

D65_Xw = 0.9504559270516716
D65_Zw = 1.0890577507598784
D50_Xw = 0.96420288
D50_Zw = 0.82490540

D65_Xr = 0.4123835774573348
D65_Xg = 0.35758636076837935
D65_Xb = 0.18048598882595746
D65_Yr = 0.21264225112116675
D65_Yg = 0.7151677022795175
D65_Yb = 0.07219004659931565
D65_Zr = 0.019324834131038457
D65_Zg = 0.11918543851645445
D65_Zb = 0.9505474781123853

D65_Rx = 3.2410639132702483
D65_Ry = -1.5374434989773638
D65_Rz = -0.49863738352233855
D65_Gx = -0.9692888172936756
D65_Gy = 1.875993314670902
D65_Gz = 0.04157078604801982
D65_Bx = 0.05564381729909414
D65_By = -0.20396692403457678
D65_Bz = 1.0569503107394616

D50_Xr = 0.43603484825656935
D50_Xg = 0.3851166943865836
D50_Xb = 0.14305133735684697
D50_Yr = 0.22248792538138254
D50_Yg = 0.7169037981483454
D50_Yb = 0.06060827647027179
D50_Zr = 0.013915901710730823
D50_Zg = 0.09706054515938503
D50_Zb = 0.7139289531298839

D50_Rx = 3.1342757757453534
D50_Ry = -1.6172769674977776
D50_Rz = -0.4907238602027905
D50_Gx = -0.9787936355010586
D50_Gy = 1.9161606866577585
D50_Gz = 0.033452266912100515
D50_Bx = 0.07197630801411643
D50_By = -0.2289831961913926
D50_Bz = 1.4057168648822214

LAB_F0 = 216 / 24389
LAB_F1 = 841 / 108
LAB_F2 = 4 / 29
LAB_F3 = LAB_F0 ** (1 / 3)
LAB_F4 = 1 / LAB_F1
LAB_F5 = 1 / 2.4
LAB_F6 = 0.04045 / 12.92
RtD = 180 / pi
DtR = pi / 180


@nb.njit(cache=True, fastmath=True)
def extrema(a: float, b: float, c: float) -> Tuple[float]:
    i = 2
    if b > c:
        b, c = c, b
        i = 1

    if a > b:
        a, b = b, a

    if b > c:
        b, c = c, b
        i = 0

    return i, a, c


@nb.njit(cache=True, fastmath=True)
def hue(r: float, g: float, b: float, d: float, i: float) -> float:
    if i == 2:
        h = 2 / 3 + (r - g) / (6 * d)
    elif i:
        h = 1 / 3 + (b - r) / (6 * d)
    else:
        h = (g - b) / (6 * d)

    return h % 1


@nb.njit(cache=True, fastmath=True)
def HSL_pixel(r: float, g: float, b: float) -> Tuple[float]:
    i, x, z = extrema(r, g, b)
    s = x + z
    d = z - x
    avg = s / 2

    return (hue(r, g, b, d, i), d / (1 - abs(s - 1)), avg) if d else (0, 0, avg)


@nb.njit(cache=True, fastmath=True)
def HSV_pixel(r: float, g: float, b: float) -> Tuple[float]:
    i, x, z = extrema(r, g, b)
    d = z - x
    return (hue(r, g, b, d, i), d / z, z) if d else (0, 0, z)


@nb.njit(cache=True, parallel=True)
def convert(img: np.ndarray, mode: Callable) -> np.ndarray:
    height, width = img.shape[:2]
    out = np.empty_like(img)
    for y in nb.prange(height):
        for x in nb.prange(width):
            a, b, c = img[y, x]
            out[y, x] = mode(a, b, c)

    return out


@nb.njit
def RGB_to_HSL(img: np.ndarray) -> np.ndarray:
    return convert(img, HSL_pixel)


@nb.njit
def RGB_to_HSV(img: np.ndarray) -> np.ndarray:
    return convert(img, HSV_pixel)


@nb.njit(cache=True, fastmath=True)
def HSL_helper(h: float, n: float) -> float:
    k = (n + 12 * h) % 12
    return max(-1, min(k - 3, 9 - k, 1))


@nb.njit(cache=True, fastmath=True)
def RGB_from_HSL_pixel(h: float, s: float, l: float):
    a = s * min(l, 1 - l)
    return l - a * HSL_helper(h, 0), l - a * HSL_helper(h, 8), l - a * HSL_helper(h, 4)


@nb.njit(cache=True, fastmath=True)
def HSV_helper(h: float, n: float) -> float:
    k = (n + 6 * h) % 6
    return max(0, min(k, 4 - k, 1))


@nb.njit(cache=True, fastmath=True)
def RGB_from_HSV_pixel(h: float, s: float, v: float):
    a = v * s
    return v - a * HSV_helper(h, 5), v - a * HSV_helper(h, 3), v - a * HSV_helper(h, 1)


@nb.njit(cache=True)
def HSL_short(h: float, s: float, l: float) -> Tuple[float]:
    return RGB_from_HSL_pixel(h, s, l) if s else (l, l, l)


@nb.njit(cache=True)
def HSV_short(h: float, s: float, v: float) -> Tuple[float]:
    return RGB_from_HSV_pixel(h, s, v) if s else (v, v, v)


@nb.njit
def HSL_to_RGB(hsl: np.ndarray) -> np.ndarray:
    return convert(hsl, HSL_short)


@nb.njit
def HSV_to_RGB(hsv: np.ndarray) -> np.ndarray:
    return convert(hsv, HSV_short)


@nb.njit(cache=True, fastmath=True)
def gamma_expand(c: float) -> float:
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


@nb.njit(cache=True, fastmath=True)
def LABF(f: float) -> float:
    return f ** (1 / 3) if f >= LAB_F0 else LAB_F1 * f + LAB_F2


@nb.njit(cache=True, fastmath=True)
def LABINVF(f: float) -> float:
    return f**3 if f >= LAB_F3 else LAB_F4 * (f - LAB_F2)


@nb.njit(cache=True, fastmath=True)
def gamma_contract(n: float) -> float:
    n = n * 12.92 if n <= LAB_F6 else (1.055 * n**LAB_F5) - 0.055
    return 0.0 if n < 0 else (1.0 if n > 1 else n)


@nb.njit(cache=True, fastmath=True)
def RGB_to_LCh_D65(r: float, g: float, b: float) -> Tuple[float]:
    b = gamma_expand(b)
    g = gamma_expand(g)
    r = gamma_expand(r)
    x = LABF((D65_Xr * r + D65_Xg * g + D65_Xb * b) / D65_Xw)
    y = LABF(D65_Yr * r + D65_Yg * g + D65_Yb * b)
    z = LABF((D65_Zr * r + D65_Zg * g + D65_Zb * b) / D65_Zw)
    m = 500 * (x - y)
    n = 200 * (y - z)
    return 116 * y - 16, (m * m + n * n) ** 0.5, (atan2(n, m) * RtD) % 360


@nb.njit(cache=True, fastmath=True)
def LCh_D65_to_RGB(l: float, c: float, h: float) -> Tuple[float]:
    h *= DtR
    l = (l + 16) / 116
    x = D65_Xw * LABINVF(l + c * cos(h) / 500)
    y = LABINVF(l)
    z = D65_Zw * LABINVF(l - c * sin(h) / 200)
    r = D65_Rx * x + D65_Ry * y + D65_Rz * z
    g = D65_Gx * x + D65_Gy * y + D65_Gz * z
    b = D65_Bx * x + D65_By * y + D65_Bz * z
    m = min(r, g, b)
    if m < 0:
        r -= m
        g -= m
        b -= m

    return gamma_contract(r), gamma_contract(g), gamma_contract(b)


@nb.njit(cache=True, fastmath=True)
def RGB_to_LCh_D50(r: float, g: float, b: float) -> Tuple[float]:
    b = gamma_expand(b)
    g = gamma_expand(g)
    r = gamma_expand(r)
    x = LABF((D50_Xr * r + D50_Xg * g + D50_Xb * b) / D50_Xw)
    y = LABF(D50_Yr * r + D50_Yg * g + D50_Yb * b)
    z = LABF((D50_Zr * r + D50_Zg * g + D50_Zb * b) / D50_Zw)
    m = 500 * (x - y)
    n = 200 * (y - z)
    return 116 * y - 16, (m * m + n * n) ** 0.5, (atan2(n, m) * RtD) % 360


@nb.njit(cache=True, fastmath=True)
def LCh_D50_to_RGB(l: float, c: float, h: float) -> Tuple[float]:
    h *= DtR
    l = (l + 16) / 116
    x = D50_Xw * LABINVF(l + c * cos(h) / 500)
    y = LABINVF(l)
    z = D50_Zw * LABINVF(l - c * sin(h) / 200)
    r = D50_Rx * x + D50_Ry * y + D50_Rz * z
    g = D50_Gx * x + D50_Gy * y + D50_Gz * z
    b = D50_Bx * x + D50_By * y + D50_Bz * z
    m = min(r, g, b)
    if m < 0:
        r -= m
        g -= m
        b -= m

    return gamma_contract(r), gamma_contract(g), gamma_contract(b)


@nb.njit(cache=True, parallel=True)
def loop_LCh(img: np.ndarray, mode: Callable) -> np.ndarray:
    height, width = img.shape[:2]
    out = np.empty_like(img)
    for y in nb.prange(height):
        for x in nb.prange(width):
            a, b, c = img[y, x]
            out[y, x] = mode(a, b, c)

    return out


@nb.njit
def IMG_to_LCh_D65(img: np.ndarray) -> np.ndarray:
    return loop_LCh(img, RGB_to_LCh_D65)


@nb.njit
def LCh_D65_to_IMG(lch: np.ndarray) -> np.ndarray:
    return loop_LCh(lch, LCh_D65_to_RGB)


@nb.njit
def IMG_to_LCh_D50(img: np.ndarray) -> np.ndarray:
    return loop_LCh(img, RGB_to_LCh_D50)


@nb.njit
def LCh_D50_to_IMG(lch: np.ndarray) -> np.ndarray:
    return loop_LCh(lch, LCh_D50_to_RGB)


SEXTET = (
    "255.{:03d}.000",
    "{:03d}.255.000",
    "000.255.{:03d}",
    "000.{:03d}.255",
    "{:03d}.000.255",
    "255.000.{:03d}",
)
SIX = (0, 510, 510, 1020, 1020, 1530)


def spectrum_position(n: int) -> str:
    n %= 1530
    i = n // 255
    return SEXTET[i].format(abs(n - SIX[i]))

blend_modes.py

import numba as nb
import numpy as np
from color import (
    RGB_to_HSL,
    RGB_to_HSV,
    HSL_to_RGB,
    HSV_to_RGB,
    IMG_to_LCh_D65,
    LCh_D65_to_IMG,
    IMG_to_LCh_D50,
    LCh_D50_to_IMG,
)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_lighten(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.maximum(base, top)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_screen(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return base + top - base * top


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_color_dodge(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.ones_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                t = top[y, x, i]
                if t != 1:
                    result[y, x, i] = min(1, base[y, x, i] / (1 - t))

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_linear_dodge(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.minimum(1, base + top)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_darken(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.minimum(base, top)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_multiply(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return base * top


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_color_burn(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.zeros_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                t = top[y, x, i]
                if t != 0:
                    result[y, x, i] = max(0, 1 - (1 - base[y, x, i]) / t)

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_linear_burn(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.maximum(0, base + top - 1.0)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_overlay(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.empty_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                b = base[y, x, i]
                t = top[y, x, i]
                result[y, x, i] = (
                    2 * b * t if b < 0.5 else 2 * b + 2 * t - 2 * b * t - 1
                )

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_soft_light(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return (1 - 2 * top) * base**2 + 2 * base * top


@nb.njit
def blend_hard_light(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return blend_overlay(top, base)


@nb.njit(cache=True, fastmath=True)
def vivid_light(b: float, t: float) -> float:
    if t < 0.5:
        return max(0, 1 - (1 - b) / (2 * t)) if t else 0
    else:
        return min(1, b / (2 - 2 * t)) if t != 1 else 1


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_vivid_light(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.zeros_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                result[y, x, i] = vivid_light(base[y, x, i], top[y, x, i])

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_linear_light(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.clip(base + 2 * top - 1, 0.0, 1.0)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_pin_light(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.zeros_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                b = base[y, x, i]
                t = top[y, x, i]
                result[y, x, i] = min(b, 2 * t) if t < 0.5 else max(b, 2 * t - 1)

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_reflect(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.ones_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                t = top[y, x, i]
                if t != 1:
                    result[y, x, i] = min(1, base[y, x, i] ** 2 / (1 - t))

    return result


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_difference(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.abs(base - top)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_exclusion(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return base + top - 2 * base * top


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_subtract(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.maximum(0, base - top)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_grain_extract(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.clip(base + top - 0.5, 0, 1)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_grain_merge(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    return np.clip(base - top + 0.5, 0, 1)


@nb.njit(cache=True, fastmath=True, parallel=True)
def blend_divide(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    height, width = base.shape[:2]
    result = np.ones_like(base)
    for y in nb.prange(height):
        for x in nb.prange(width):
            for i in (0, 1, 2):
                t = top[y, x, i]
                if t != 0:
                    result[y, x, i] = min(1, base[y, x, i] / t)

    return result


@nb.njit
def blend_HSV_Color(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    hsv = RGB_to_HSV(base)
    hsv[..., 0:2] = RGB_to_HSV(top)[..., 0:2]
    return HSV_to_RGB(hsv)


@nb.njit
def blend_HSL_Color(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    hsl = RGB_to_HSL(base)
    hsl[..., 0:2] = RGB_to_HSL(top)[..., 0:2]
    return HSL_to_RGB(hsl)


@nb.njit
def blend_color_lux(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    hsv = RGB_to_HSV(base)
    hsv[..., 0:2] = RGB_to_HSV(top)[..., 0:2]
    return HSL_to_RGB(hsv)


@nb.njit
def blend_color_nox(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    hsl = RGB_to_HSL(base)
    hsl[..., 0:2] = RGB_to_HSL(top)[..., 0:2]
    return HSV_to_RGB(hsl)


@nb.njit
def blend_color_LCh_D65(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    lch = IMG_to_LCh_D65(base)
    lch[..., 1:3] = IMG_to_LCh_D65(top)[..., 1:3]
    return LCh_D65_to_IMG(lch)


@nb.njit
def blend_color_LCh_D50(base: np.ndarray, top: np.ndarray) -> np.ndarray:
    lch = IMG_to_LCh_D50(base)
    lch[..., 1:3] = IMG_to_LCh_D50(top)[..., 1:3]
    return LCh_D50_to_IMG(lch)

theme.py

import numpy as np
from basic_data import *
from itertools import chain
from PIL import Image
from PyQt6.QtGui import QIcon, QImage, QPixmap
from typing import Callable, Tuple


WINDOWS = (
    "QMainWindow",
    "QMessageBox",
    "QWidget#Window",
    "QWidget#Picker",
)

WINDOWS = (
    ", ".join(w for window in WINDOWS for w in (window, f"{window}::title"))
    + " { background: "
)


class Style_Compiler:
    @staticmethod
    def validate(entries: dict, widget: str) -> None:
        if entries.keys() - NORMAL_KEYS:
            raise ValueError(
                f"dictionary contains keys that cannot be parsed by this class, widget: {widget}, dictionary: {entries!r}"
            )

    def __init__(
        self,
        widget: str,
        entries: dict,
        round: bool = True,
        keys: list = None,
        vertical: bool = False,
    ) -> None:
        self.validate(entries, widget)
        self.entries = entries
        self.data = {}
        self.keys = keys
        self.round = round
        self.vertical = vertical
        self.widget = widget

    def parse_background(self) -> None:
        if background := self.entries.get("background"):
            if self.entries.keys() & {"lowlight", "highlight"}:
                raise ValueError("background cannot be both plain and gradient")

            self.data["background"] = background

    def parse_gradient(self) -> None:
        highlight = self.entries.get("highlight")
        lowlight = self.entries.get("lowlight")
        if highlight or lowlight:
            if bool(highlight) - bool(lowlight):
                raise ValueError("gradient must have two end points")

            if "background" in self.entries:
                raise ValueError("background cannot be both plain and gradient")

            self.data["background"] = GRADIENT[self.vertical].format(
                lowlight=lowlight, highlight=highlight
            )

    def parse_border(self) -> None:
        bordercolor = self.entries.get("bordercolor")
        borderstyle = self.entries.get("borderstyle")
        if bordercolor or borderstyle:
            if bordercolor and borderstyle:
                self.data["border"] = f"3px {borderstyle} {bordercolor}"

            elif bordercolor:
                self.data["border-color"] = bordercolor

            else:
                self.data["border-style"] = borderstyle

            if self.round:
                self.data["border-radius"] = "6px"

    def parse_color(self) -> None:
        if textcolor := self.entries.get("textcolor"):
            self.data["color"] = textcolor

    def parse(self) -> None:
        self.parse_background()
        self.parse_gradient()
        self.parse_border()
        self.parse_color()
        self.extras = EXTRA_ATTRIBUTES.get(self.widget, {})

    def format_items(self) -> str:
        items = chain.from_iterable(
            [
                ((k, self.data[k]) for k in self.keys)
                if self.keys
                else self.data.items(),
                self.extras.items(),
            ]
        )

        return "\n".join(f"    {k}: {v};" for k, v in items)

    def compile_style(self) -> str:
        self.parse()
        return self.widget + " {\n" + self.format_items() + "\n}\n"


class Style_Combiner:
    def __init__(self, config: dict) -> None:
        self.config = config
        self.compilers = []
        self._get_normal_compilers()
        self._get_special_compilers()

    def _get_normal_compilers(self) -> None:
        self.compilers.extend(
            [
                Style_Compiler(widget, self.config[style])
                for widget, style in WIDGETS["normal"].items()
            ]
        )

    def _get_special_compilers(self) -> None:
        self.compilers.extend(
            [
                Style_Compiler(widget, self.config[style], *args)
                for widget, (style, *args) in WIDGETS["special"].items()
            ]
        )

    def board_style(self) -> str:
        return BOARD_BASE.format_map(self.config["board_base"])

    def hover_label_style(self) -> str:
        return HOVER_LABEL_BASE.format_map(self.config["combobox_menu"])

    def background_style(self) -> str:
        return WINDOWS + self.config["window"]["background"] + "; }\n"

    def combobox_style(self) -> str:
        base = self.config["combobox"]
        menu = self.config["combobox_menu"]
        entries = {}
        for k in BOX:
            entries[f"{k}_1"] = base[k]
            entries[f"{k}_2"] = menu[k]

        return COMBOBOX_BASE.format(
            textcolor=base["textcolor"],
            hoverbase=menu["hoverbase"],
            hovercolor=menu["hovercolor"],
            **entries,
        )

    def mock_dropdown_style(self) -> str:
        return MOCK_MENU_BASE.format_map(self.config["combobox_menu"])

    def special_styles(self) -> str:
        return (
            self.background_style()
            + self.board_style()
            + self.combobox_style()
            + self.hover_label_style()
            + self.mock_dropdown_style()
        )

    def get_style(self) -> str:
        return (
            IMMUTABLE
            + self.special_styles()
            + "".join(compiler.compile_style() for compiler in self.compilers)
        )


class Icon:
    def __init__(self, path: str) -> None:
        self.img = np.array(Image.open(path))
        self.height, self.width = self.img.shape[:2]
        self.base = (self.img / 255)[..., 0:3]
        self.color = np.zeros_like(self.base)
        self.set_icon()

    def set_color(self, color: Tuple[int]) -> None:
        self.color[...] = [i / 255 for i in color]

    def set_blend(self, blend: Callable) -> None:
        self.blend = blend
        self.img[..., 0:3] = (
            (blend(self.base, self.color) * 255).round().astype(np.uint8)
        )

    def set_icon(self) -> None:
        self.qimage = QImage(
            bytearray(self.img), self.width, self.height, QImage.Format.Format_RGBA8888
        )
        self.qsize = self.qimage.size()
        self.qpixmap = QPixmap(self.qimage)
        self.qicon = QIcon(self.qpixmap)


class Piece:
    def __init__(self, color: Tuple[int], blend: str, choice: str, player: str) -> None:
        GLOBALS[player] = self
        self.choices = {shape: Icon(f"{FOLDER}/icons/{shape}.png") for shape in SHAPES}
        self.color = color
        self.blend = BLEND_MODES[blend]
        self.set_active(choice)
        self.set_icon()

    def __set_blend(self) -> None:
        self.active.set_blend(self.blend)

    def set_active(self, choice: str) -> None:
        self.active = self.choices[choice]
        self.set_color()
        self.__set_blend()
        self.set_icon()

    def set_color(self) -> None:
        self.active.set_color(self.color)
        self.__set_blend()
        self.set_icon()

    def set_blend(self, blend: str) -> None:
        self.blend = BLEND_MODES[blend]
        self.__set_blend()
        self.set_icon()

    def set_icon(self) -> None:
        self.active.set_icon()


if __name__ == "__main__":
    combiner = Style_Combiner(CONFIG)
    print(combiner.get_style())

./config/default_theme.json

(also theme.json by default)

{
    "window": {
        "background": "#201a33"
    },
    "label": {
        "textcolor": "#b2ffff"
    },
    "win": {
        "textcolor": "#ffff00"
    },
    "loss": {
        "textcolor": "#ff00ff"
    },
    "tie": {
        "textcolor": "#00ffff"
    },
    "board_base": {
        "background": "#140a33",
        "bordercolor": "#632b80",
        "borderstyle": "groove"
    },
    "board_hover": {
        "lowlight": "#a33ba3",
        "highlight": "#ff5cff",
        "bordercolor": "#ff00ff",
        "borderstyle": "outset"
    },
    "player1_base": {
        "lowlight": "#6345bf",
        "highlight": "#855cff",
        "bordercolor": "#4000ff",
        "borderstyle": "inset"
    },
    "player1_win": {
        "lowlight": "#30bfbf",
        "highlight": "#b2ffff",
        "bordercolor": "#00ffff",
        "borderstyle": "inset"
    },
    "player2_base": {
        "lowlight": "#a36629",
        "highlight": "#ff9f40",
        "bordercolor": "#ff8000",
        "borderstyle": "inset"
    },
    "player2_win": {
        "lowlight": "#bfbf30",
        "highlight": "#ffffb2",
        "bordercolor": "#ffff00",
        "borderstyle": "inset"
    },
    "groupbox_p1": {
        "background": "#331980",
        "bordercolor": "#402080",
        "borderstyle": "groove"
    },
    "groupbox_p2": {
        "background": "#801980",
        "bordercolor": "#402080",
        "borderstyle": "groove"
    },
    "groupbox_stats": {
        "background": "#311755",
        "bordercolor": "#402080",
        "borderstyle": "groove"
    },
    "groupbox": {
        "background": "#211740",
        "bordercolor": "#402080",
        "borderstyle": "groove"
    },
    "combobox": {
        "background": "#422e80",
        "textcolor": "#c000ff",
        "bordercolor": "#552b80",
        "borderstyle": "outset"
    },
    "combobox_menu": {
        "background": "#422e80",
        "hoverbase": "#7800d7",
        "hovercolor": "#ffb2ff",
        "bordercolor": "#8000c0",
        "borderstyle": "groove"
    },
    "button": {
        "lowlight": "#5454c0",
        "highlight": "#3fbfff",
        "textcolor": "#ff3fff",
        "bordercolor": "#4080c0"
    },
    "button_hover": {
        "lowlight": "#00a3a3",
        "highlight": "#5cffff",
        "textcolor": "#0000ff",
        "bordercolor": "#0080ff"
    },
    "button_pressed": {
        "lowlight": "#00a3a3",
        "highlight": "#00b2b2",
        "textcolor": "#000080",
        "bordercolor": "#4080c0"
    },    
    "squarebutton": {
        "background": "#382080",
        "bordercolor": "#5319ff"
    },
    "checkbox": {
        "textcolor": "#b2ffff"
    },
    "checkbox_hover": {
        "lowlight": "#8040c0",
        "highlight": "#ff00ff",
        "textcolor": "#0000ff"
    },
    "checkbox_pressed": {
        "lowlight": "#8040c0",
        "highlight": "#550072",
        "textcolor": "#ffb2ff"
    },
    "radiobutton": {
        "textcolor": "#b2ffff"
    },
    "radiobutton_indicator": {
        "bordercolor": "#4000ff",
        "borderstyle": "solid"
    },
    "radiobutton_hover": {
        "lowlight": "#8040c0",
        "highlight": "#ff00ff",
        "textcolor": "#0000ff"
    },
    "radiobutton_pressed": {
        "lowlight": "#8040c0",
        "highlight": "#550072",
        "textcolor": "#ffb2ff"
    },
    "spinbox": {
        "background": "#422e80",
        "textcolor": "#c000ff",
        "bordercolor": "#8000c0",
        "borderstyle": "groove"
    },
    "lineedit": {
        "background": "#422e80",
        "textcolor": "#c000ff",
        "bordercolor": "#8000c0",
        "borderstyle": "groove"
    },
    "scrollframe": {
        "bordercolor": "#ff3399",
        "borderstyle": "groove"
    },
    "scrollbar": {
        "lowlight": "#504080",
        "highlight": "#6652a3",
        "bordercolor": "#4000ff",
        "borderstyle": "groove"
    },
    "handle": {
        "lowlight": "#e617e6",
        "highlight": "#ff80ff"
    },
    "arrow": {
        "background": "#331980",
        "bordercolor": "#7040ff",
        "borderstyle": "outset"
    },
    "titlebar": {
        "lowlight": "#400640",
        "highlight": "#ff19ff"
    }
}

./config/widget_groups.json

{
    "Board": [
        ["Board Cell", "board_base"],
        ["Board Hover", "board_hover"],
        ["Player 1", "player1_base"],
        ["Player 2", "player2_base"],
        ["Player 1 Win", "player1_win"],
        ["Player 2 Win", "player2_win"]
    ],
    "Button": [
        ["Button Base", "button"],
        ["Button Hover", "button_hover"],
        ["Button Pressed", "button_pressed"]
    ],
    "CheckBox": [
        ["CheckBox Base", "checkbox"],
        ["CheckBox Hover", "checkbox_hover"],
        ["CheckBox pressed", "checkbox_pressed"]
    ],
    "ComboBox": [
        ["ComboBox", "combobox"],
        ["ComboBox Menu", "combobox_menu"]
    ],
    "LineEdit": [
        ["LineEdit", "lineedit"]
    ],
    "Miscellaneous": [
        ["Player 1 Box", "groupbox_p1"],
        ["Player 2 Box", "groupbox_p2"],
        ["Stats Box", "groupbox_stats"],
        ["Groupbox", "groupbox"],
        ["Label Win", "win"],
        ["Label Loss", "loss"],
        ["Label Tie", "tie"],
        ["Label", "label"],
        ["Background", "window"],
        ["Title Bar", "titlebar"],
        ["Title Button", "squarebutton"]
    ],
    "RadioButton": [
        ["RadioButton", "radiobutton"],
        ["Radio Indicator", "radiobutton_indicator"],
        ["Radio Hover", "radiobutton_hover"],
        ["Radio Pressed", "radiobutton_pressed"]
    ],
    "ScrollArea": [
        ["ScrollArea Frame", "scrollframe"],
        ["Scrollbar", "scrollbar"],
        ["Scroll Handle", "handle"],
        ["Scroll Arrow", "arrow"]
    ],
    "SpinBox": [
        ["SpinBox", "spinbox"]
    ]
}

./config/widgets.json

{
    "normal": {
        "QCheckBox": "checkbox",
        "QCheckBox:hover, QCheckBox#Hover": "checkbox_hover",
        "QCheckBox:pressed, QCheckBox#Pressed": "checkbox_pressed",
        "QFrame#Scroll": "scrollframe",
        "QGroupBox": "groupbox",
        "QGroupBox#P1": "groupbox_p1",
        "QGroupBox#P2": "groupbox_p2",
        "QGroupBox#Stats": "groupbox_stats",
        "QLabel": "label",
        "QLabel#Cell": "board_base",
        "QLabel#Loss": "loss",
        "QLabel#P1Win": "player1_win",
        "QLabel#P2Win": "player2_win",
        "QLabel#Tie": "tie",
        "QLabel#Win": "win",
        "QLabel#Edit, QLineEdit": "lineedit",
        "QPushButton, DummyButton#Base": "button",
        "QSpinBox": "spinbox",
        "QLabel#Hover": "board_hover",
        "QTableWidget#Game::item:hover": "board_hover",
        "QLabel#P1, QPushButton#P1": "player1_base",
        "QLabel#P2, QPushButton#P2": "player2_base",
        "DummyButton#Hover, QPushButton:hover, QPushButton#P1:hover, QPushButton#P2:hover": "button_hover",
        "DummyButton#Pressed, QPushButton:pressed, QPushButton#P1:pressed, QPushButton#P2:pressed": "button_pressed",
        "QRadioButton, QRadioButton#Base": "radiobutton",
        "QRadioButton:hover, QRadioButton#Hover": "radiobutton_hover",
        "QRadioButton:pressed, QRadioButton#Pressed": "radiobutton_pressed",
        "SquareButton": "squarebutton",
        "TitleBar": "titlebar"
    },
    "special": {
        "QRadioButton#Base::indicator, QRadioButton#Hover::indicator, QRadioButton#Pressed::indicator, QRadioButton::indicator": [
            "radiobutton_indicator",
            false
        ],
        "QLabel#Mock": [
            "combobox",
            false,
            [
                "color"
            ]
        ],
        "QGroupBox#Top": [
            "combobox",
            true,
            [
                "background",
                "border"
            ]
        ],
        "QScrollBar::vertical": [
            "scrollbar",
            false,
            null,
            true
        ],
        "QScrollBar::handle:vertical": [
            "handle",
            false,
            null,
            true
        ],
        "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical": [
            "arrow",
            false
        ]
    }
}

I can confirm there are no bugs in this section, everything is working as intended, I have achieved everything I wanted to achieve, although there are functions I wished that could be faster.

How can I improve the code structure, performance and in general?

(Icons can be found in the repository I linked above. You will need them to run the code, or you will encounter file not found error)


Update

It has been two days since the questions were posted, and I haven't received a response yet, so it is time to bump them.

I have made a few changes. First I have fixed a small bug.

Then I made some redesigns of the UI, as you can see, inside playerboxes the widgets are now positioned in the center, and I have made the score labels wide enough so that they can show 6 digit numbers.

And obviously, I have added the ability to customize the title bar. I have also made every window completely unresizable, and I added emojis to the game over messages.

And last but not least, I have made the random styles half-decent, I have generated hue, saturation and lightness values separately, the hues have a minimum value of 180 and a maximum value of 330 and they are biased to 249.33, if the target is a background color, else it can be any hue.

The saturation values have a lower bound of 0.5 and upper bound of 1 (1 being full saturation), and they are biased towards 0.7284.

And HSV values of the colors are determined by the role of the color keys, each role has a lower bound and an upper bound and target value.

And then the HSV color is converted to RGB color, this ensures the generated styles don't look downright awful to me, but I don't know others' preferences.

And now the program cannot randomly choose "dashed", "dotted", "hidden", "none" border styles, and the 6 remaining border styles have different chances to be chosen as well.

\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.