Adding a Colour Space#

../_images/colour_space.webp

YIQ Colour Space, Y constant 50#

The hsv colour wheel was easily determined from the fact that hue at 100% value and 100% saturation corresponded to all the primary, secondary and tertiary colours. In the yiq colour space the primary colours depend upon all three components. But we do know that the hue changes only with the I and Q components, therefore it makes sense to use I and Q as our variables and fix Y, somewhat arbitrarily, on 50%.

Like the colour wheel there is no real need for a scale, it will show up on the gradients. In Wikipedia the y axis was flipped so that the axis follows mathematical convention with increasing y going vertically upwards.

Show/Hide Code 02yiq_colour_space.py

""" Construction yiq colour space

Parameters
----------
None

Results
-------
image
"""

from PIL import Image
from tkinter import Tk
from yiqTools import yiq_to_rgb
     

root = Tk()
winsys = root.tk.call("tk", "windowingsystem")
BASELINE = 1.33398982438864281 if winsys != 'aqua' else 1.000492368291482
scaling = root.tk.call("tk", "scaling")
enlargement = e = int(scaling / BASELINE + 0.5)

im = Image.new("RGB", (301*e,301*e), "#FFFFFF")
centre = im.size[0] // 2, im.size[1] // 2
pix = im.load()

for x in range(im.width):
    for y in range(im.height):
        i=(x-centre[0])*2/3
        q=(y-centre[1])*2/3
        pix[x,y]=yiq_to_rgb(50,i,q)

im.save('../../figures/colour_space'+str(e)+'.png')

Now change 01basicyiq.py into 03yiqspaceadded.py whilst importing the image created from 02yiq_colourspace.py. Add the function for a circle, insert the canvas for the colour space, together with its cursor - include the activeoutline option. See that it all fits together.

Now start making the cursor interactive. As we are dealing with a square colour space there is no requirement for special functions to convert x,y to I,Q and back again. When calling the cursor function door_bell, keep the Y component at the current value, rather than 50 (which was used to draw the space). Now add the binds for the cursor and their functions.

Change the bind functions to reflect cartesian coordinates. Ensure the cursor stays inside the canvas, convert the x and y coordinates to the I and Q values.

../_images/yiq_space.webp

Colour Space and Cursor Inserted - Showing Red#

Show/Hide Code 03yiqspaceaddedlist.py

""" Construction three gradients in yiq using PPM image
    spinbox validation, working with modified Scale and
    colour space
"""

from tkinter import Tk, Canvas, Label, Frame, PhotoImage, StringVar
from tkinter.ttk import LabelFrame, Scale, Style, Spinbox
from PIL import Image, ImageDraw, ImageTk
from yiqTools import  circle, yiq_to_rgb, draw_gradient, yiq_okay


class TtkScale(Scale):
    """Class to draw themed Scale widget

    Parameters
    ----------
    parent : str
        parent widget
    from_ : int
        start of scale
    to : int
        end of scale
    length : int
        length in pixels
    orient : str
        orientation
    variable : str
        tk variable
    digits : int
        length variable when converted to string
    tickinterval : float or int
        how many digits show up in tick interval
    sliderlength : int
        what it says
    command : str
        procedure called when slider moves
    """

    def __init__(self, parent, from_=0, to=255, length=300, orient='horizontal',
                 variable=0, digits=None, tickinterval=None, sliderlength=16,
                 command=None):
        self.from_ = from_
        self.to = to
        self.variable = variable

        super().__init__(parent, length=length + sliderlength, orient=orient,
                         variable=variable, from_=from_, to=to, command=command)

        self.digits = digits
        self.length = length

        self.build(parent, from_, to, sliderlength, tickinterval, length)

    def build(self, parent, from_, to, sliderlength, tickinterval, length):
        """Create ticks

        Parameters
        ----------
        parent : str
            parent widget
        from_ : int
            start of scale
        to : int
            end of scale
        length : int
            length in pixels
        tickinterval : float or int

        """

        sc_range = to - from_

        if tickinterval:
            for i in range(from_, to + 2, tickinterval):
                item = Label(parent, text=i, bg='#EFFEFF')
                j = (i if from_ > 0 else i - from_)
                item.place(in_=self, bordermode='outside',
                           relx=sliderlength / length / 2 +
                           j / sc_range * (1 - sliderlength / length),
                           rely=1, anchor='n')


class YiqSelect:
    """Class to construct yiq gradients

    Parameters
    ----------
    fr0 : str
        parent widget

    """

    def __init__(self, fr0, enlargement):
        self.fr0 = fr0
        self.e = enlargement

        self.yvar = StringVar()
        self.ivar = StringVar()
        self.qvar = StringVar()

        self.scale_l = 300*self.e
        self.sliderlength = 16*self.e
        self.canvas_w = self.scale_l-self.sliderlength
        self.canvas_h = 26*self.e
        self.cursor_w = 16*self.e
        self.canvas_b = 30*self.e

        self.space = 301*self.e
        self.ring_radius = 10*self.e
        self.ring_width = 3*self.e

        self.yvar.set(30)
        self.ivar.set(100)
        self.qvar.set(40.56)

        self.build()

    def yiqhandle(self, evt=None):
        """command callback for y, i or q"""

        y = float(self.yvar.get())
        i = float(self.ivar.get())
        q = float(self.qvar.get())
        from_colour = yiq_to_rgb(*(0, i, q))
        to_colour = yiq_to_rgb(*(100, i, q))
        draw_gradient(self.cans[0], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)
        from_colour = yiq_to_rgb(*(y, -100, q))
        to_colour = yiq_to_rgb(*(y, 100, q))
        draw_gradient(self.cans[1], from_colour, to_colour,
                        width=self.canvas_w, height=self.canvas_h)
        from_colour = yiq_to_rgb(*(y, i, -100))
        to_colour = yiq_to_rgb(*(y, i, 100))
        draw_gradient(self.cans[2], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)

    def door_bell(self, ring):
        """bind callback from cursor"""

        i, q = ring
        y = float(self.yvar.get())
        # yiq = (y, i, q)
        from_colour = yiq_to_rgb(*(y, 0, q))
        to_colour = yiq_to_rgb(*(y, 100, q))
        draw_gradient(self.cans[1], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)
        from_colour = yiq_to_rgb(*(y, i, 0))
        to_colour = yiq_to_rgb(*(y, i, 100))
        draw_gradient(self.cans[2], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)

    def build(self):
        """widget construction"""

        fr4 = LabelFrame(self.fr0, text='yiq')
        fr4.grid(column=2, row=0)
        vcmdyiq = root.register(yiq_okay)

        self.cans = []
        sboxes = []
        comps = ['y', 'i', 'q']
        names = ['luma', 'i hue', 'q hue']
        tkvars = [self.yvar, self.ivar, self.qvar]
        froms = [0, -100, -100]
        ticks = [10, 20, 20]

        for ix, comp in enumerate(comps):
            Label(fr4, text=names[ix]).grid(row=3*ix, column=0)
            Label(fr4, height=1).grid(row=2+3*ix, column=2)
            self.cans.append(Canvas(fr4, width=self.canvas_w, height=self.canvas_h,
                bd=0, highlightthickness=0))
            self.cans[ix].grid(row=3*ix, column=1)
            TtkScale(fr4, from_=froms[ix], to=100, variable=tkvars[ix],
                orient='horizontal', length=self.scale_l, command=self.yiqhandle,
                tickinterval=ticks[ix]).grid(row=1+3*ix, column=1, sticky='nw')
            sboxes.append(Spinbox(fr4, from_=froms[ix], to=100, textvariable=tkvars[ix],
                validatecommand=(vcmdyiq, '%d', '%P', '%S', froms[ix], 100),
                validate='key', command=self.yiqhandle, width=5,
                increment=1))
            sboxes[ix].grid(row=1+3*ix, column=2, sticky='nw')
            sboxes[ix].bind('<KeyRelease>', self.checksyiq)

        # assume initial setting 0,100,100 hsv
        to_colour = yiq_to_rgb(*(30, 100.0, 40.56))
        # print(self.canvas_w)
        draw_gradient(self.cans[0], yiq_to_rgb(0.0, 100.0, 40.56),
                      yiq_to_rgb(100, 100, 40.56),
                      width=self.canvas_w, height=self.canvas_h)
        draw_gradient(self.cans[1], yiq_to_rgb(30, -100.0, 40.56), to_colour,
                      width=self.canvas_w, height=self.canvas_h)
        draw_gradient(self.cans[2], yiq_to_rgb(30, 100, -100),
                      yiq_to_rgb(30, 100, 100),
                      width=self.canvas_w, height=self.canvas_h)

        self.can_yiq = can_yiq = Canvas(fr4, width=self.space, height=self.space)
        can_yiq.grid(column=1, row=9, pady=25, sticky='n')
        self.yiqGamut = PhotoImage(file='../../figures/colour_space'+str(self.e)+'.png')
        can_yiq.create_image(0, 0, anchor='nw', image=self.yiqGamut)
        self.ring = circle(can_yiq, 300.0*self.e, 210.84*self.e, self.ring_radius,
                           width=self.ring_width,
                           activeoutline='#555555', tags='ring')  # 240, 181

        can_yiq.bind('<Button-1>', self.move_ring)
        can_yiq.tag_bind('ring', '<B1-Motion>', self.move_ring)

    def move_ring(self, event):
        """Procedure called when mouse clicked or dragged in colour wheel

        Parameters
        ----------
        evt : str
            bind handle

        Returns
        -------
        calls handle for cursor
        """

        X = event.x
        Y = event.y
        ring_radius = self.ring_radius
        space = self.space

        # check whether inside space
        X = min(max(X, 0), space)
        Y = min(max(Y, 0), space)

        for search in self.can_yiq.find_withtag("ring"):
            self.can_yiq.coords(
                search,
                X - ring_radius,
                Y - ring_radius,
                X + ring_radius,
                Y + ring_radius)

        i = (X - space // 2) * 2 / 3 / self.e
        q = (Y - space // 2) * 2 / 3 / self.e
        #print(X,Y,space,'X,Y,space')
        #print(i,q, 'i,q')
        self.ivar.set(i)
        self.qvar.set(q)
        ring = i, q
        self.door_bell(ring)

    def checksyiq(self, evt):
        """Procedure called by yiq spinboxes

        Parameters
        ----------
        evt : str
            bind handles

        """

        y = float(self.yvar.get())
        i = float(self.ivar.get())
        q = float(self.qvar.get())
        from_colour = yiq_to_rgb(*(0, i, q))
        to_colour = yiq_to_rgb(*(100, i, q))
        draw_gradient(self.cans[0], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)
        from_colour = yiq_to_rgb(*(y, -100, q))
        to_colour = yiq_to_rgb(*(y, 100, q))
        draw_gradient(self.cans[1], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)
        from_colour = yiq_to_rgb(*(y, i, -100))
        to_colour = yiq_to_rgb(*(y, i, 100))
        draw_gradient(self.cans[2], from_colour, to_colour,
                      width=self.canvas_w, height=self.canvas_h)

if __name__ == "__main__":
    root = Tk()
    winsys = root.tk.call("tk", "windowingsystem")
    BASELINE = 1.33398982438864281 if winsys != 'aqua' else 1.000492368291482
    scaling = root.tk.call("tk", "scaling")
    enlargement = e = int(scaling / BASELINE + 0.5)

    img = Image.new("RGBA", (16*e, 10*e), '#00000000')
    trough = ImageTk.PhotoImage(img)

    # constants for creating upward pointing arrow
    WIDTH = 17*e
    HEIGHT = 17*e
    OFFSET = 5*e
    ST0 = WIDTH // 2, HEIGHT - 1 - OFFSET
    LIGHT = 'GreenYellow'
    MEDIUM = 'LawnGreen'
    DARK = '#5D9B90'

    # normal state
    im = Image.new("RGBA", (WIDTH, HEIGHT), '#00000000')
    rdraw = ImageDraw.Draw(im)
    rdraw.polygon([ST0[0], ST0[1], 0, HEIGHT - 1,
                   WIDTH - 1, HEIGHT - 1], fill=LIGHT)
    rdraw.polygon([ST0[0], ST0[1], ST0[0], 0, 0, HEIGHT - 1], fill=MEDIUM)
    rdraw.polygon([ST0[0], ST0[1], WIDTH - 1,
                   HEIGHT - 1, ST0[0], 0], fill=DARK)
    slider = ImageTk.PhotoImage(im)

    # pressed state
    imp = Image.new("RGBA", (WIDTH, HEIGHT), '#00000000')
    draw = ImageDraw.Draw(imp)
    draw.polygon([ST0[0], ST0[1], 0, HEIGHT - 1,
                  WIDTH - 1, HEIGHT - 1], fill=LIGHT)
    draw.polygon([ST0[0], ST0[1], ST0[0], 0, 0, HEIGHT - 1], fill=DARK)
    draw.polygon([ST0[0], ST0[1], WIDTH - 1,
                  HEIGHT - 1, ST0[0], 0], fill=MEDIUM)
    sliderp = ImageTk.PhotoImage(imp)

    style = Style()
    style.theme_settings('default', {
        'Horizontal.Scale.trough': {"element create":
                                    ('image', trough,
                                     {'border': 0, 'sticky': 'wes'})},
        'Horizontal.Scale.slider': {"element create":
                                    ('image', slider,
                                     ('pressed', sliderp),
                                     {'border': 3, 'sticky': 'n'})}})

    style.theme_use('default')
    style.configure('TSpinbox', arrowsize=10*e)
    fr = Frame(root)
    fr.grid(row=0, column=0, sticky='nsew')
    YiqSelect(fr, enlargement)
    root.mainloop()

Note

Check on the Gradient Appearance

Move the scale cursors around and look at the gradients. If they look too grey now is the time to change them to become yiq gradients.